diff --git a/.github/workflows/rust-windows.yml b/.github/workflows/rust-windows.yml new file mode 100644 index 000000000..bd194a073 --- /dev/null +++ b/.github/workflows/rust-windows.yml @@ -0,0 +1,77 @@ +name: rust-windows + +on: + push: + branches: [ master ] + pull_request: + branches: [ '**' ] + types: [opened, synchronize, reopened, ready_for_review] + merge_group: + +env: + CARGO_TERM_COLOR: always + # aws-lc-sys (rustls' default provider) assembles with NASM on x86-64; the + # crate ships prebuilt objects behind this switch, so the runner needs no + # extra install. ARM64 hosts need clang-cl instead. + AWS_LC_SYS_PREBUILT_NASM: 1 + +jobs: + windows: + strategy: + fail-fast: false + matrix: + include: + - { arch: x86-64, os: windows-latest } + - { arch: arm64, os: windows-11-arm } + runs-on: ${{ matrix.os }} + name: Windows ${{ matrix.arch }} + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # stable + with: + toolchain: stable + # aws-lc-sys' ARM assembly is GNU-syntax, which MSVC cannot assemble: the + # build then fails late in lib.exe, archiving objects that were never + # written. clang-cl assembles it and stays ABI-compatible with the MSVC + # toolchain the Rust side links with. The runner image ships it; the step + # reports what it found, and warns loudly if it ever has to install one. + - name: Use clang-cl for C dependencies + if: matrix.arch == 'arm64' + shell: pwsh + run: | + $clang = Get-Command clang-cl -ErrorAction SilentlyContinue + if ($clang) { + Write-Host "clang-cl: $($clang.Source)" + } else { + $found = Get-ChildItem "$env:ProgramFiles\Microsoft Visual Studio", "$env:ProgramFiles\LLVM" ` + -Recurse -Filter clang-cl.exe -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty DirectoryName + if ($found) { + Write-Host "::warning::clang-cl was not on PATH; using $found" + Add-Content $env:GITHUB_PATH $found + } else { + Write-Host "::warning::the runner image no longer ships clang-cl; installing LLVM" + choco install llvm -y --no-progress + Add-Content $env:GITHUB_PATH "$env:ProgramFiles\LLVM\bin" + } + } + # cc-rs splits these on whitespace, so the bare name has to resolve on PATH. + Add-Content $env:GITHUB_ENV "CC=clang-cl" + Add-Content $env:GITHUB_ENV "CXX=clang-cl" + + # josh-ssh-shell is unix-only (unix sockets, fifos, raw fds), so the + # workspace does not build here. + - name: Build + run: cargo build --locked -p josh-proxy -p josh-cli + # josh compose needs podman and does not run on Windows, so the .t suites + # are out of reach; these are the crates whose unit tests run here. + - name: Unit tests + run: cargo test --locked -p josh-core -p josh-filter -p josh-gix-ext -p josh-git-serde -p josh-search -p josh-memodb + # Drives the built binaries, since the .t suites cannot run here: the CLI + # against a local repository, and josh-proxy against a git server hosted by + # HttpListener, so the job needs nothing that Windows does not ship. + - name: Functional tests + run: pwsh tests/windows/run.ps1 target/debug -PathForms diff --git a/Cargo.lock b/Cargo.lock index 8794d849b..22d79184c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3313,6 +3313,7 @@ dependencies = [ "anyhow", "chrono", "git2", + "gix-hash", "josh-core", "josh-git-serde", "serde", @@ -3328,8 +3329,10 @@ dependencies = [ "clap", "defer", "dirs", + "dunce", "env_logger", "git2", + "gix-hash", "glob", "josh-changes", "josh-compose", @@ -3349,6 +3352,7 @@ dependencies = [ "reqwest-middleware", "serde", "serde_json", + "tempfile", "tokio", ] @@ -3368,7 +3372,7 @@ version = "26.7.28" dependencies = [ "anyhow", "defer", - "git2", + "gix-hash", "josh-compose-backend", "josh-core", "josh-filter", @@ -3448,6 +3452,7 @@ dependencies = [ "axum", "clap", "git2", + "gix-hash", "josh-core", "josh-github-webhooks", "josh-link", @@ -3479,7 +3484,6 @@ name = "josh-filter" version = "26.7.28" dependencies = [ "anyhow", - "git2", "gix-hash", "gix-object", "glob", @@ -3499,7 +3503,6 @@ name = "josh-git-serde" version = "26.7.28" dependencies = [ "anyhow", - "git2", "gix-hash", "gix-object", "josh-gix-ext", @@ -3537,7 +3540,7 @@ name = "josh-github-changes" version = "26.7.28" dependencies = [ "anyhow", - "git2", + "gix-hash", "josh-changes", "josh-core", "josh-git-serde", @@ -3569,7 +3572,7 @@ version = "26.7.28" dependencies = [ "anyhow", "async-trait", - "git2", + "gix-hash", "graphql_client", "http", "josh-github-auth", @@ -3641,6 +3644,7 @@ dependencies = [ "anyhow", "chrono", "git2", + "gix-hash", "glob", "josh-core", "josh-search", @@ -3659,6 +3663,7 @@ version = "26.7.28" dependencies = [ "anyhow", "git2", + "gix-hash", "josh-core", ] @@ -3692,9 +3697,11 @@ dependencies = [ "base64 0.23.0", "bon", "clap", + "dunce", "futures", "git2", "gix", + "gix-hash", "gix-packetline", "gix-transport", "hex", @@ -3718,6 +3725,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "socket2", "tempfile", "thiserror 2.0.19", "tokio", @@ -3784,6 +3792,7 @@ dependencies = [ "allocative", "anyhow", "git2", + "gix-hash", "gix-object", "josh-filter", "josh-gix-ext", @@ -3796,7 +3805,7 @@ version = "26.7.28" dependencies = [ "anyhow", "form_urlencoded", - "git2", + "gix-hash", "handlebars", "josh-core", "josh-graphql", diff --git a/Cargo.toml b/Cargo.toml index de203f3e4..0eae93c86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,8 @@ serde_json = "1.0.151" serde_yaml = "0.9.34" toml = "1.1.4" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +dunce = "1.0.5" +socket2 = "0.6.3" tempfile = "3.27.0" hex = "0.4.3" secret-vault-value = "^1" diff --git a/cq/josh-cq/Cargo.toml b/cq/josh-cq/Cargo.toml index a9ae9da2b..a05a29d0d 100644 --- a/cq/josh-cq/Cargo.toml +++ b/cq/josh-cq/Cargo.toml @@ -14,6 +14,7 @@ anyhow.workspace = true serde.workspace = true serde_json.workspace = true git2.workspace = true +gix-hash.workspace = true axum.workspace = true tracing.workspace = true diff --git a/cq/josh-cq/src/cq.rs b/cq/josh-cq/src/cq.rs index ed4e7d1de..5dde8412b 100644 --- a/cq/josh-cq/src/cq.rs +++ b/cq/josh-cq/src/cq.rs @@ -68,7 +68,7 @@ pub fn handle_track( url, None, "HEAD", - fetched_commit, + josh_core::objects::gix_oid(fetched_commit), josh_core::objects::CommitData::read(transaction.odb(), head.commit)?.tree_id()?, link_mode, )? diff --git a/cq/josh-cq/src/remote.rs b/cq/josh-cq/src/remote.rs index e9c1c7ed5..33624a33a 100644 --- a/cq/josh-cq/src/remote.rs +++ b/cq/josh-cq/src/remote.rs @@ -1,5 +1,6 @@ use anyhow::Context; use anyhow::anyhow; +use std::str::FromStr; use std::collections::BTreeMap; use std::process::Command; @@ -7,7 +8,7 @@ use std::process::Command; /// List refs from a remote repository using git ls-remote /// /// Returns a map of ref names to their OIDs -pub fn list_refs(url: &str) -> anyhow::Result> { +pub fn list_refs(url: &str) -> anyhow::Result> { let output = Command::new("git") .args(["ls-remote", url]) .output() @@ -19,12 +20,12 @@ pub fn list_refs(url: &str) -> anyhow::Result> { } let stdout = String::from_utf8(output.stdout)?; - let refs: BTreeMap = stdout + let refs: BTreeMap = stdout .lines() .filter_map(|line| { let parts: Vec<&str> = line.split('\t').collect(); if parts.len() == 2 { - let oid = git2::Oid::from_str(parts[0]).ok()?; + let oid = gix_hash::ObjectId::from_str(parts[0]).ok()?; Some((parts[1].to_string(), oid)) } else { None diff --git a/devtools/josh-test-support/src/bench.rs b/devtools/josh-test-support/src/bench.rs index e116a25ae..34eb21859 100644 --- a/devtools/josh-test-support/src/bench.rs +++ b/devtools/josh-test-support/src/bench.rs @@ -33,12 +33,12 @@ pub fn random_string(rng: &mut StdRng, len: usize) -> String { pub fn build_index( repo: &git2::Repository, sig: &git2::Signature, - heads: &[git2::Oid], -) -> Result { + heads: &[gix::ObjectId], +) -> Result { let empty_tree = repo.find_tree(repo.treebuilder(None)?.write()?)?; let parents = heads .iter() - .map(|oid| repo.find_commit(*oid)) + .map(|oid| repo.find_commit(git2_oid(*oid))) .collect::, _>>()?; let parent_refs = parents.iter().collect::>(); let index = repo.commit( @@ -49,7 +49,7 @@ pub fn build_index( &empty_tree, &parent_refs, )?; - Ok(index) + Ok(gix_oid(index)) } /// Rebuild, with plain git2 tree walking (no josh code), the tree a pattern filter must produce: @@ -60,10 +60,10 @@ pub fn build_index( /// `require_literal_leading_dot`; a glob-based predicate is exact regardless). pub fn expected_tree( repo: &git2::Repository, - head: git2::Oid, + head: gix::ObjectId, keep: &dyn Fn(&str) -> bool, -) -> Result<(git2::Oid, usize)> { - let tree = repo.find_commit(head)?.tree()?; +) -> Result<(gix::ObjectId, usize)> { + let tree = repo.find_commit(git2_oid(head))?.tree()?; let mut kept: Vec<(String, git2::Oid, i32)> = vec![]; tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { if entry.kind() == Some(git2::ObjectType::Blob) { @@ -85,5 +85,5 @@ pub fn expected_tree( }; builder.upsert(path.as_str(), mode, gix_oid(*oid))?; } - Ok((git2_oid(builder.write()?.detach()), kept.len())) + Ok((builder.write()?.detach(), kept.len())) } diff --git a/devtools/josh-test-support/src/provision_repo.rs b/devtools/josh-test-support/src/provision_repo.rs index ff8c404ea..a97c7304e 100644 --- a/devtools/josh-test-support/src/provision_repo.rs +++ b/devtools/josh-test-support/src/provision_repo.rs @@ -20,7 +20,7 @@ pub struct ProvisionedRepo { pub repo: git2::Repository, /// The head oid the callback produced (always equal to the `expected` that /// was passed to [`provision_repo`]). - pub head: git2::Oid, + pub head: gix::ObjectId, } impl ProvisionedRepo { @@ -46,11 +46,11 @@ impl ProvisionedRepo { /// new `expected`. pub fn provision_repo( testcase: &str, - expected: &git2::Oid, + expected: &gix::ObjectId, callback: C, ) -> Result where - C: FnMut(&git2::Repository) -> Result, + C: FnMut(&git2::Repository) -> Result, { let expected = *expected; let cache_root = cache_root_for(testcase)?; @@ -71,17 +71,19 @@ fn cache_root_for(testcase: &str) -> Result { } /// A cached repo is reusable if it opens as a bare repo and contains `expected`. -fn cache_hit(cache_root: &Path, expected: git2::Oid) -> bool { +fn cache_hit(cache_root: &Path, expected: gix::ObjectId) -> bool { let Ok(repo) = git2::Repository::open_bare(cache_root) else { return false; }; - repo.odb().map(|odb| odb.exists(expected)).unwrap_or(false) + repo.odb() + .map(|odb| odb.exists(crate::bench::git2_oid(expected))) + .unwrap_or(false) } /// Build `testcase` from scratch into `cache_root`, erasing any prior cache. -fn rebuild(cache_root: &Path, expected: git2::Oid, callback: &mut C) -> Result<()> +fn rebuild(cache_root: &Path, expected: gix::ObjectId, callback: &mut C) -> Result<()> where - C: FnMut(&git2::Repository) -> Result, + C: FnMut(&git2::Repository) -> Result, { if cache_root.exists() { std::fs::remove_dir_all(cache_root) @@ -130,7 +132,7 @@ where } /// Copy the canonical cached repo into a fresh tempdir and open it. -fn copy_to_tempdir(cache_root: &Path, expected: git2::Oid) -> Result { +fn copy_to_tempdir(cache_root: &Path, expected: gix::ObjectId) -> Result { let tmp = tempfile::tempdir().context("creating tempdir for repo copy")?; copy_dir_recursive(cache_root, tmp.path()) .with_context(|| format!("copying {} to tempdir", cache_root.display()))?; diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index c8cdd511f..0165810ff 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -29,5 +29,6 @@ # Contributing - [Testing](./contributing/testing.md) - [Development tools](./contributing/dev-tools.md) +- [Windows](./contributing/windows.md) - [josh run](./contributing/josh-run.md) - [Tracing]() diff --git a/docs/src/contributing/windows.md b/docs/src/contributing/windows.md new file mode 100644 index 000000000..b72329d05 --- /dev/null +++ b/docs/src/contributing/windows.md @@ -0,0 +1,45 @@ +# Windows + +Windows support is experimental. `josh-proxy` and the `josh` / `josh-filter` CLIs build and run; +SSH serving and `josh compose` do not (see [Limitations](#limitations)). + +## Setup + +```powershell +winget install Rustlang.Rustup +winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --passive --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended" +winget install Git.Git +``` + +Git for Windows is needed at runtime, not just to clone: josh shells out to `git`, and the hooks +it installs are `sh` shims that git runs with the bundled sh. + +On ARM64, also install LLVM and build with clang-cl: the `aws-lc-sys` dependency has GNU-syntax +ARM assembly that MSVC cannot assemble, and the build otherwise fails late in `lib.exe` with +LNK1181, archiving object files that were never written. + +```powershell +winget install LLVM.LLVM +$env:Path += ';C:\Program Files\LLVM\bin'; $env:CC='clang-cl'; $env:CXX='clang-cl' +``` + +Pass the bare name via `PATH` rather than a full path in `CC`: cc-rs splits that variable on +whitespace. + +## Build + +`josh-ssh-shell` is unix-only, so build the supported binaries rather than the workspace: + +```powershell +cargo build --release -p josh-proxy -p josh-cli +``` + +## Limitations + +* SSH is not supported on Windows. +* `josh compose run` is not supported on Windows, so the repository's own test suite does not + run there. +* An upstream whose path contains a reserved Windows device name (`aux`, `con`, `nul`, + `com1`..`com9`, `lpt1`..`lpt9`) cannot be mirrored: the namespace becomes a path, and Windows + has no such filename. +* Windows support is experimental. diff --git a/forges/josh-github-changes/Cargo.toml b/forges/josh-github-changes/Cargo.toml index 8d1cb928d..987e15385 100644 --- a/forges/josh-github-changes/Cargo.toml +++ b/forges/josh-github-changes/Cargo.toml @@ -9,7 +9,7 @@ repository = "https://github.com/josh-project/josh" keywords = ["git", "github", "monorepo", "workflow"] [dependencies] -git2.workspace = true +gix-hash.workspace = true anyhow.workspace = true url.workspace = true serde.workspace = true diff --git a/forges/josh-github-changes/src/prs.rs b/forges/josh-github-changes/src/prs.rs index 15f5d6aca..438659e67 100644 --- a/forges/josh-github-changes/src/prs.rs +++ b/forges/josh-github-changes/src/prs.rs @@ -31,7 +31,7 @@ pub fn read_pr_data( pub struct PrInfo { pub head_branch: String, pub base_branch: String, - pub base_oid: git2::Oid, + pub base_oid: gix_hash::ObjectId, pub title: String, pub body: String, } @@ -46,8 +46,8 @@ pub fn collect_pr_infos( struct ByIdEntry { head_branch: Option, base_branch: Option, - head_oid: Option, - base_oid: Option, + head_oid: Option, + base_oid: Option, } fn branch_name(refname: &str) -> &str { @@ -313,6 +313,7 @@ pub async fn create_or_update_prs( #[cfg(test)] mod tests { use super::*; + use std::str::FromStr; const TIP: &str = "1111111111111111111111111111111111111111"; const OTHER: &str = "2222222222222222222222222222222222222222"; @@ -321,7 +322,7 @@ mod tests { PrInfo { head_branch: "@changes/main/a@b.com/feature".to_string(), base_branch: "@base/main/a@b.com/feature".to_string(), - base_oid: git2::Oid::from_str(base_oid).unwrap(), + base_oid: gix_hash::ObjectId::from_str(base_oid).unwrap(), title: "t".to_string(), body: "b".to_string(), } diff --git a/forges/josh-github-graphql/Cargo.toml b/forges/josh-github-graphql/Cargo.toml index 3a15fe1ac..1db9e7422 100644 --- a/forges/josh-github-graphql/Cargo.toml +++ b/forges/josh-github-graphql/Cargo.toml @@ -13,7 +13,7 @@ doctest = false [dependencies] http.workspace = true -git2.workspace = true +gix-hash.workspace = true serde.workspace = true serde_json.workspace = true reqwest.workspace = true diff --git a/forges/josh-github-graphql/src/operations/create_check_run.rs b/forges/josh-github-graphql/src/operations/create_check_run.rs index c874d0521..57d03d183 100644 --- a/forges/josh-github-graphql/src/operations/create_check_run.rs +++ b/forges/josh-github-graphql/src/operations/create_check_run.rs @@ -8,7 +8,7 @@ use crate::connection::GithubApiConnection; impl GithubApiConnection { pub async fn create_check_run( &self, - head_sha: &git2::Oid, + head_sha: &gix_hash::ObjectId, name: &str, repository_id: &str, status: create_check_run::RequestableCheckStatusState, diff --git a/josh-changes/Cargo.toml b/josh-changes/Cargo.toml index 3d0513b26..0a296ac9d 100644 --- a/josh-changes/Cargo.toml +++ b/josh-changes/Cargo.toml @@ -12,6 +12,7 @@ keywords = ["git", "monorepo", "workflow", "scm"] anyhow.workspace = true chrono.workspace = true git2.workspace = true +gix-hash.workspace = true serde.workspace = true josh-core.workspace = true diff --git a/josh-changes/src/change.rs b/josh-changes/src/change.rs index b36260ab0..6e42c3f61 100644 --- a/josh-changes/src/change.rs +++ b/josh-changes/src/change.rs @@ -3,20 +3,21 @@ use crate::store::store_diff_data; use anyhow::{Context, anyhow}; use josh_core::objects; use josh_core::trailers::{commit_change_meta, parse_change_meta}; +use std::str::FromStr; #[derive(Debug, Clone)] pub struct Change { pub(crate) author: String, pub(crate) id: Option, pub(crate) series: Vec, - pub(crate) commit: git2::Oid, - pub(crate) base: git2::Oid, + pub(crate) commit: gix_hash::ObjectId, + pub(crate) base: gix_hash::ObjectId, } impl Change { pub fn new( transaction: &josh_core::cache::Transaction, - commit: git2::Oid, + commit: gix_hash::ObjectId, ) -> anyhow::Result { Ok(Self::from_commit(&objects::CommitData::read( transaction.odb(), @@ -35,7 +36,7 @@ impl Change { id: None, series: Vec::new(), commit: commit.id(), - base: git2::Oid::ZERO_SHA1, + base: gix_hash::ObjectId::null(gix_hash::Kind::Sha1), }; let (id, series) = commit_change_meta(commit); change.id = id; @@ -56,29 +57,31 @@ impl Change { &self.series } - pub fn commit(&self) -> git2::Oid { + pub fn commit(&self) -> gix_hash::ObjectId { self.commit } - pub fn base(&self) -> git2::Oid { + pub fn base(&self) -> gix_hash::ObjectId { self.base } - pub fn set_base(&mut self, base: git2::Oid) { + pub fn set_base(&mut self, base: gix_hash::ObjectId) { self.base = base; } pub fn contributing( &self, transaction: &josh_core::cache::Transaction, - ) -> anyhow::Result> { + ) -> anyhow::Result> { // First-parent walk down to (but not including) the base. let odb = transaction.odb(); let mut walk = objects::RevWalk::new(odb); walk.simplify_first_parent(); walk.push(self.commit)?; let base = self.base; - let mut oids = walk.into_topo_vec(|oid| base != git2::Oid::ZERO_SHA1 && oid == base)?; + let mut oids = walk.into_topo_vec(|oid| { + base != gix_hash::ObjectId::null(gix_hash::Kind::Sha1) && oid == base + })?; oids.retain(|oid| *oid != base); if oids.first() == Some(&self.commit) { oids.remove(0); @@ -96,10 +99,10 @@ pub fn encode_change_id_path(id: &str) -> String { /// merge needed). Author and committer are copied from the head commit. pub fn create_synthetic_merge_commit( transaction: &josh_core::cache::Transaction, - pr_head: git2::Oid, - target_branch_tip: git2::Oid, + pr_head: gix_hash::ObjectId, + target_branch_tip: gix_hash::ObjectId, message: &str, -) -> anyhow::Result { +) -> anyhow::Result { let odb = transaction.odb(); let head = objects::CommitData::read(odb, pr_head)?; let tree = head.tree_id()?; @@ -115,9 +118,11 @@ pub fn create_synthetic_merge_commit( pub(crate) fn split_changes( transaction: &josh_core::cache::Transaction, - changes: std::collections::HashMap, + changes: std::collections::HashMap, ) -> anyhow::Result> { - if changes.values().next().map(|c| c.base) == Some(git2::Oid::ZERO_SHA1) { + if changes.values().next().map(|c| c.base) + == Some(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)) + { return Ok(changes.into_values().collect()); } @@ -135,14 +140,16 @@ pub(crate) fn split_changes( pub(crate) fn get_changes( transaction: &josh_core::cache::Transaction, - tip: git2::Oid, - base: git2::Oid, -) -> anyhow::Result> { + tip: gix_hash::ObjectId, + base: gix_hash::ObjectId, +) -> anyhow::Result> { let odb = transaction.odb(); let mut walk = objects::RevWalk::new(odb); walk.simplify_first_parent(); walk.push(tip)?; - let mut oids = walk.into_topo_vec(|oid| base != git2::Oid::ZERO_SHA1 && oid == base)?; + let mut oids = walk.into_topo_vec(|oid| { + base != gix_hash::ObjectId::null(gix_hash::Kind::Sha1) && oid == base + })?; oids.retain(|oid| *oid != base); oids.reverse(); @@ -162,8 +169,8 @@ pub(crate) fn get_changes( pub fn sync_changes( transaction: &josh_core::cache::Transaction, - tip: git2::Oid, - base: git2::Oid, + tip: gix_hash::ObjectId, + base: gix_hash::ObjectId, branch: &str, ) -> anyhow::Result> { let changes = get_changes(transaction, tip, base)?; @@ -201,9 +208,11 @@ pub fn list_changes( let mut changes = Vec::new(); for (change_id, data) in entries { - let tip_oid = git2::Oid::from_str(&data.commit).unwrap_or(git2::Oid::ZERO_SHA1); - let base_oid = git2::Oid::from_str(&data.base).unwrap_or(git2::Oid::ZERO_SHA1); - if tip_oid == git2::Oid::ZERO_SHA1 { + let tip_oid = gix_hash::ObjectId::from_str(&data.commit) + .unwrap_or(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)); + let base_oid = gix_hash::ObjectId::from_str(&data.base) + .unwrap_or(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)); + if tip_oid == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { continue; } let commit = match objects::CommitData::read(odb, tip_oid) { @@ -222,12 +231,12 @@ pub fn list_changes( pub fn resolve_change( transaction: &josh_core::cache::Transaction, - head: git2::Oid, + head: gix_hash::ObjectId, spec: &str, ) -> anyhow::Result { let odb = transaction.odb(); // Try as a full OID first. - if let Ok(oid) = git2::Oid::from_str(spec) { + if let Ok(oid) = gix_hash::ObjectId::from_str(spec) { if let Ok(commit) = objects::CommitData::read(odb, oid) { return Ok(Change::from_commit(&commit)); } diff --git a/josh-changes/src/comments.rs b/josh-changes/src/comments.rs index 92df50055..d7b2c0f4c 100644 --- a/josh-changes/src/comments.rs +++ b/josh-changes/src/comments.rs @@ -7,6 +7,7 @@ use josh_core::filter::tree; use josh_core::memodb::Odb; use josh_core::objects; use josh_git_serde::{from_tree_oid, from_value}; +use std::str::FromStr; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Location { @@ -113,7 +114,7 @@ fn write_comment_inner( let prefix = std::path::Path::new(path_prefix); let path = if let Some(ref file) = meta.file { let resolve_commit = match blob_commit_override { - Some(s) => git2::Oid::from_str(s)?, + Some(s) => gix_hash::ObjectId::from_str(s)?, None => change.commit(), }; let commit_tree = objects::CommitData::read(odb, resolve_commit)?.tree_id()?; @@ -243,7 +244,7 @@ pub fn comment_author( fn read_comment( odb: &Odb, id: &str, - entry_oid: git2::Oid, + entry_oid: gix_hash::ObjectId, file: Option, ) -> anyhow::Result { let value = from_tree_oid(odb, entry_oid)?; @@ -383,7 +384,7 @@ pub fn read_comments( fn collect_comments_at_prefix( transaction: &Transaction, odb: &Odb, - tree: git2::Oid, + tree: gix_hash::ObjectId, change_id: &str, prefix: &str, pending: bool, @@ -403,7 +404,7 @@ fn collect_comments_at_prefix( ) { for entry in tree::read_tree(transaction, odb, cid_tree)?.entries() { let id = String::from_utf8_lossy(entry.filename).into_owned(); - let mut c = read_comment(odb, &id, objects::git2_oid(&entry.oid), None) + let mut c = read_comment(odb, &id, entry.oid.to_owned(), None) .with_context(|| format!("undecodable comment {} on change {}", id, change_id))?; c.pending = pending; out.push(c); @@ -446,13 +447,13 @@ fn collect_comments_under_into( transaction: &Transaction, odb: &Odb, change_id: &str, - tree: git2::Oid, + tree: gix_hash::ObjectId, file_prefix: &std::path::Path, out: &mut Vec, ) -> anyhow::Result<()> { for entry in tree::read_tree(transaction, odb, tree)?.entries() { let name = std::str::from_utf8(entry.filename).unwrap_or(""); - let entry_oid = objects::git2_oid(&entry.oid); + let entry_oid = entry.oid.to_owned(); let file = if file_prefix.as_os_str().is_empty() { None } else { @@ -539,7 +540,13 @@ pub fn delete_outbox_comments( tree::get_path_entry(transaction, odb, tree, path), Ok(Some(_)) ) { - tree = tree::insert_oid(odb, tree, path, git2::Oid::ZERO_SHA1, 0)?; + tree = tree::insert_oid( + odb, + tree, + path, + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + 0, + )?; } } @@ -636,7 +643,7 @@ pub fn store_fetched_comments( fn collect_outbox_file_paths( transaction: &Transaction, odb: &Odb, - tree: git2::Oid, + tree: gix_hash::ObjectId, cur: &std::path::Path, want: &std::collections::HashSet<&str>, out: &mut Vec, @@ -651,7 +658,7 @@ fn collect_outbox_file_paths( collect_outbox_file_paths( transaction, odb, - objects::git2_oid(&entry.oid), + entry.oid.to_owned(), &cur.join(name), want, out, diff --git a/josh-changes/src/forges/gerrit.rs b/josh-changes/src/forges/gerrit.rs index f968d792a..1fc5a71c7 100644 --- a/josh-changes/src/forges/gerrit.rs +++ b/josh-changes/src/forges/gerrit.rs @@ -1,6 +1,5 @@ use crate::change::{Change, get_changes, split_changes}; use crate::stacked::PushRef; -use anyhow::anyhow; /// A valid Gerrit Change-Id is the letter `I` followed by 40 hex digits. fn is_gerrit_change_id(id: &str) -> bool { @@ -18,8 +17,7 @@ fn gerrit_change_id(josh_id: &str) -> anyhow::Result { if is_gerrit_change_id(josh_id) { return Ok(josh_id.to_string()); } - let oid = git2::Oid::hash_object(git2::ObjectType::Blob, josh_id.as_bytes()) - .map_err(|e| anyhow!("failed to derive Gerrit Change-Id: {}", e))?; + let oid = josh_core::objects::hash_blob(josh_id.as_bytes()); Ok(format!("I{}", oid)) } @@ -75,13 +73,13 @@ fn message_with_gerrit_change_id(message: &str, gerrit_id: &str) -> String { /// change rather than a duplicate. fn rewrite_chain_with_gerrit_ids( transaction: &josh_core::cache::Transaction, - tip: git2::Oid, - base: git2::Oid, -) -> anyhow::Result { + tip: gix_hash::ObjectId, + base: gix_hash::ObjectId, +) -> anyhow::Result { let odb = transaction.odb(); // Collect the chain from tip down to (but excluding) base, first parent only. - let mut chain: Vec = Vec::new(); + let mut chain: Vec = Vec::new(); let mut cur = tip; while cur != base { chain.push(cur); @@ -92,7 +90,7 @@ fn rewrite_chain_with_gerrit_ids( } chain.reverse(); - let mut new_parent = (base != git2::Oid::ZERO_SHA1).then_some(base); + let mut new_parent = (base != gix_hash::ObjectId::null(gix_hash::Kind::Sha1)).then_some(base); for oid in chain { let commit = josh_core::objects::CommitData::read(odb, oid)?; let (josh_id, _) = josh_core::trailers::commit_change_meta(&commit); @@ -135,8 +133,8 @@ fn rewrite_chain_with_gerrit_ids( pub fn build_gerrit_push( transaction: &josh_core::cache::Transaction, branch: &str, - tip: git2::Oid, - base: git2::Oid, + tip: gix_hash::ObjectId, + base: gix_hash::ObjectId, ) -> anyhow::Result> { if tip == base { return Ok(vec![]); @@ -165,8 +163,8 @@ pub fn build_gerrit_push( pub fn build_gerrit_independent_push( transaction: &josh_core::cache::Transaction, branch: &str, - tip: git2::Oid, - base: git2::Oid, + tip: gix_hash::ObjectId, + base: gix_hash::ObjectId, ) -> anyhow::Result> { let odb = transaction.odb(); let changes = get_changes(transaction, tip, base)?; @@ -188,7 +186,7 @@ pub fn build_gerrit_independent_push( let parent = josh_core::objects::CommitData::read(odb, change.commit)? .parent_ids() .next(); - let has_no_deps = if base == git2::Oid::ZERO_SHA1 { + let has_no_deps = if base == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { parent.is_none() } else { parent == Some(base) diff --git a/josh-changes/src/refs.rs b/josh-changes/src/refs.rs index 1107d09b4..1a3c7d1d2 100644 --- a/josh-changes/src/refs.rs +++ b/josh-changes/src/refs.rs @@ -256,10 +256,11 @@ impl ChangesRef { } /// Return the changes ref's target OID, if it exists. -pub fn read_ref_oid(repo: &git2::Repository, scope: &ChangesRef) -> Option { +pub fn read_ref_oid(repo: &git2::Repository, scope: &ChangesRef) -> Option { repo.find_reference(&scope.ref_name()) .ok() .and_then(|r| r.target()) + .map(josh_core::objects::gix_oid) } /// Read HEAD and return the current branch shorthand. Errors on a diff --git a/josh-changes/src/revisions.rs b/josh-changes/src/revisions.rs index cbdfdc8df..f72e7abcb 100644 --- a/josh-changes/src/revisions.rs +++ b/josh-changes/src/revisions.rs @@ -26,7 +26,7 @@ pub fn read_revisions( }; // The change's diffs subtree, when the commit has one. - let diffs_of = |tree: git2::Oid| -> Option { + let diffs_of = |tree: gix_hash::ObjectId| -> Option { crate::store::get_tree( transaction, odb, diff --git a/josh-changes/src/stacked.rs b/josh-changes/src/stacked.rs index a1c0cdd9f..407d8b169 100644 --- a/josh-changes/src/stacked.rs +++ b/josh-changes/src/stacked.rs @@ -16,7 +16,7 @@ pub enum PushMode { #[derive(Debug, Clone)] pub struct PushRef { pub ref_name: String, - pub oid: git2::Oid, + pub oid: gix_hash::ObjectId, pub change_id: String, } @@ -115,8 +115,8 @@ pub fn build_to_push( push_mode: &PushMode, baseref: &str, ref_with_options: &str, - oid_to_push: git2::Oid, - base_oid: git2::Oid, + oid_to_push: gix_hash::ObjectId, + base_oid: gix_hash::ObjectId, ) -> anyhow::Result> { match push_mode { PushMode::Publish(author) => { diff --git a/josh-changes/src/store.rs b/josh-changes/src/store.rs index cd154d07d..c6256435c 100644 --- a/josh-changes/src/store.rs +++ b/josh-changes/src/store.rs @@ -10,14 +10,14 @@ use josh_git_serde::GitValue; pub(crate) fn get_tree( transaction: &Transaction, odb: &Odb, - root: git2::Oid, + root: gix_hash::ObjectId, path: &std::path::Path, -) -> Option { +) -> Option { tree::get_path_entry(transaction, odb, root, path) .ok() .flatten() .filter(|entry| entry.mode.is_tree()) - .map(|entry| objects::git2_oid(&entry.oid)) + .map(|entry| entry.oid.to_owned()) } /// The tree of `scope`'s ref, or `None` when the ref does not exist. @@ -25,7 +25,7 @@ pub fn scope_tree( transaction: &Transaction, odb: &Odb, scope: &ChangesRef, -) -> anyhow::Result> { +) -> anyhow::Result> { match transaction.resolve_ref(&scope.ref_name())? { Some(oid) => Ok(Some(objects::CommitData::read(odb, oid)?.tree_id()?)), None => Ok(None), @@ -50,7 +50,7 @@ pub(crate) fn parse_timestamp(s: Option<&str>) -> git2::Time { pub fn write_changes_tree( transaction: &Transaction, path: &std::path::Path, - blob_oid: git2::Oid, + blob_oid: gix_hash::ObjectId, author: Option<&str>, timestamp: Option<&str>, scope: &ChangesRef, @@ -65,7 +65,7 @@ pub fn write_changes_tree( // Skip if the blob already exists at this path. if let Ok(Some(existing)) = tree::get_path_entry(transaction, odb, base_tree, path) { - if objects::git2_oid(&existing.oid) == blob_oid { + if existing.oid.to_owned() == blob_oid { return Ok(()); } } @@ -103,7 +103,7 @@ pub fn write_value( author: Option<&str>, timestamp: Option<&str>, scope: &ChangesRef, -) -> anyhow::Result { +) -> anyhow::Result { let (root, mode) = value_oid(transaction, data)?; place_oid(transaction, path, root, mode, author, timestamp, scope)?; Ok(root) @@ -116,7 +116,7 @@ pub fn write_value( pub fn value_oid( transaction: &Transaction, data: &T, -) -> anyhow::Result<(git2::Oid, i32)> { +) -> anyhow::Result<(gix_hash::ObjectId, i32)> { let odb = transaction.odb(); let value = josh_git_serde::to_value(data)?; let root = josh_git_serde::to_tree_oid(odb, &value)?; @@ -132,7 +132,7 @@ pub fn value_oid( pub fn place_oid( transaction: &Transaction, path: &std::path::Path, - root: git2::Oid, + root: gix_hash::ObjectId, mode: i32, author: Option<&str>, timestamp: Option<&str>, @@ -147,7 +147,7 @@ pub fn place_oid( }; if let Ok(Some(existing)) = tree::get_path_entry(transaction, odb, base_tree, path) { - if objects::git2_oid(&existing.oid) == root { + if existing.oid.to_owned() == root { return Ok(()); } } @@ -235,7 +235,7 @@ pub fn store_diff_data( let path = std::path::Path::new("diffs").join(encode_change_id_path(&change_id)); if let Ok(Some(existing)) = tree::get_path_entry(transaction, odb, base_tree, &path) { - if objects::git2_oid(&existing.oid) == tree_oid { + if existing.oid == tree_oid { return Ok(()); } } @@ -254,7 +254,7 @@ pub fn store_diff_data( "josh\n", )?; - let mut parents: Vec = Vec::new(); + let mut parents: Vec = Vec::new(); parents.extend(prev_tip); parents.push(anchor_oid); let msg = format!("update {}\n", ref_name); @@ -315,7 +315,13 @@ pub fn delete_change( tree::get_path_entry(transaction, odb, tree, &path), Ok(Some(_)) ) { - tree = tree::insert_oid(odb, tree, &path, git2::Oid::ZERO_SHA1, 0)?; + tree = tree::insert_oid( + odb, + tree, + &path, + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + 0, + )?; } } diff --git a/josh-changes/src/votes.rs b/josh-changes/src/votes.rs index 1f3bb039b..0fb7fda37 100644 --- a/josh-changes/src/votes.rs +++ b/josh-changes/src/votes.rs @@ -190,7 +190,13 @@ pub fn delete_outbox_votes( tree::get_path_entry(transaction, odb, tree, &path), Ok(Some(_)) ) { - tree = tree::insert_oid(odb, tree, &path, git2::Oid::ZERO_SHA1, 0)?; + tree = tree::insert_oid( + odb, + tree, + &path, + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + 0, + )?; removed += 1; } } diff --git a/josh-cli/Cargo.toml b/josh-cli/Cargo.toml index 6499e4367..9f25740a6 100644 --- a/josh-cli/Cargo.toml +++ b/josh-cli/Cargo.toml @@ -17,8 +17,10 @@ log.workspace = true serde_json.workspace = true defer.workspace = true clap.workspace = true +dunce.workspace = true juniper.workspace = true git2.workspace = true +gix-hash.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } serde.workspace = true chrono = { workspace = true, features = ["clock"] } @@ -41,6 +43,9 @@ josh-graphql.workspace = true josh-link.workspace = true josh-templates.workspace = true +[dev-dependencies] +tempfile.workspace = true + [features] codesign = [] diff --git a/josh-cli/src/bin/josh-filter.rs b/josh-cli/src/bin/josh-filter.rs index 8ec9a1c20..f6cf37942 100644 --- a/josh-cli/src/bin/josh-filter.rs +++ b/josh-cli/src/bin/josh-filter.rs @@ -2,15 +2,16 @@ use anyhow::{Context, anyhow}; use std::fs::read_to_string; +use std::str::FromStr; fn resolve_input_ref( transaction: &josh_core::cache::Transaction, input_ref: &str, -) -> anyhow::Result<(String, git2::Oid)> { +) -> anyhow::Result<(String, gix_hash::ObjectId)> { let oid = josh_core::git::resolve_snapshot_input(transaction, input_ref)?; let ref_string = if input_ref == "+" || input_ref == "." { oid.to_string() - } else if git2::Oid::from_str(input_ref).is_ok() { + } else if gix_hash::ObjectId::from_str(input_ref).is_ok() { input_ref.to_string() } else if let Some(name) = transaction.expand_ref_name(input_ref)? { name @@ -144,7 +145,7 @@ struct GitNotesFilterHook { impl josh_core::cache::FilterHook for GitNotesFilterHook { fn filter_for_commit( &self, - commit_oid: git2::Oid, + commit_oid: gix_hash::ObjectId, arg: &str, ) -> anyhow::Result { let notes_ref = if arg.starts_with("refs/") { @@ -154,7 +155,10 @@ impl josh_core::cache::FilterHook for GitNotesFilterHook { }; let repo = self.repo.lock().unwrap(); let note = repo - .find_note(Some(notes_ref.as_str()), commit_oid) + .find_note( + Some(notes_ref.as_str()), + josh_core::objects::git2_oid(&commit_oid), + ) .context("missing git note for commit")?; let msg = note.message().context("empty git note")?; josh_core::filter::parse(msg) @@ -206,15 +210,13 @@ fn run_filter(args: Vec) -> anyhow::Result { }; transaction = transaction.with_filter_hook(std::sync::Arc::new(hook)); - let repo = transaction.git2_repo(); - // If the filter spec doesn't contain a colon and it's not from a file, // treat it as a SHA and read from tree let mut filterobj = if specstr.contains(':') || is_from_file { josh_core::filter::parse(&specstr)? } else { // Try to parse as SHA and read filter from tree - let tree_oid = git2::Oid::from_str(specstr.trim()) + let tree_oid = gix_hash::ObjectId::from_str(specstr.trim()) .with_context(|| format!("Invalid filter spec or SHA: {}", specstr))?; josh_core::filter::from_tree(&transaction, tree_oid)? }; @@ -222,7 +224,7 @@ fn run_filter(args: Vec) -> anyhow::Result { let input_ref = args.get_one::("input").unwrap(); let mut refs = vec![]; - let mut ids: Vec<(git2::Oid, josh_core::filter::Filter)> = vec![]; + let mut ids = vec![]; let (input_ref, oid) = resolve_input_ref(&transaction, input_ref)?; refs.push((input_ref.clone(), oid)); @@ -242,7 +244,7 @@ fn run_filter(args: Vec) -> anyhow::Result { if !matcher.matches(name) { return Ok(()); } - let target = repo.find_object(oid, None)?.peel_to_commit()?.id(); + let target = josh_core::objects::peel_to_commit(transaction.odb(), oid)?; ids.push((target, josh_core::filter::Filter::new().message(name))); refs.push((name.to_string(), target)); Ok(()) @@ -258,8 +260,8 @@ fn run_filter(args: Vec) -> anyhow::Result { for line in reflist.lines() { let split = line.split(' ').collect::>(); if let [sha, name] = split.as_slice() { - let target = git2::Oid::from_str(sha)?; - let target = repo.find_object(target, None)?.peel_to_commit()?.id(); + let target = gix_hash::ObjectId::from_str(sha)?; + let target = josh_core::objects::peel_to_commit(transaction.odb(), target)?; ids.push((target, josh_core::filter::Filter::new().message(name))); refs.push((name.to_string(), target)); } else if !split.is_empty() { @@ -326,7 +328,7 @@ fn run_filter(args: Vec) -> anyhow::Result { let old_oid = transaction .resolve_ref(target)? - .unwrap_or(git2::Oid::ZERO_SHA1); + .unwrap_or(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)); let (mut updated_refs, errors) = josh_core::filter_refs(&transaction, filterobj, &refs); @@ -394,7 +396,7 @@ fn run_filter(args: Vec) -> anyhow::Result { // rev-parse reads them through the repository handle, which only sees disk. transaction.flush_mem_odb()?; - let rev = |spec: &str| -> anyhow::Result { + let rev = |spec: &str| -> anyhow::Result { transaction .rev_parse(spec)? .ok_or_else(|| anyhow!("no such revision: {}", spec)) diff --git a/josh-cli/src/bin/josh.rs b/josh-cli/src/bin/josh.rs index dd3c349eb..19f7fb8ed 100644 --- a/josh-cli/src/bin/josh.rs +++ b/josh-cli/src/bin/josh.rs @@ -336,28 +336,32 @@ fn to_absolute_remote_url(url: &str) -> anyhow::Result { { Ok(url.to_owned()) } else { - // For local paths, make them absolute - let path = std::fs::canonicalize(url) + // dunce, not std: on Windows std::fs::canonicalize returns an extended-length path + // (\\?\C:\...), which git rejects inside a file:// URL (issue #2288). + let path = dunce::canonicalize(url) .with_context(|| format!("Failed to resolve path {}", url))? .display() .to_string(); + // A UNC path keeps its authority (file://server/share/...); a drive path does not. + #[cfg(windows)] + let path = match path.strip_prefix(r"\\") { + Some(unc) => unc.replace('\\', "/"), + None => format!("/{}", path.replace('\\', "/")), + }; + Ok(format!("file://{}", path)) } } -/// Initial clone setup: create directory, init repo, add remote (no transaction needed) +/// Initialize a clone and configure its remote. fn clone_repo(args: &CloneArgs) -> anyhow::Result { - // Use the provided output directory let output_dir = args.out.clone(); - // Create the output directory first std::fs::create_dir_all(&output_dir)?; - // Initialize a new git repository inside the directory using git2 git2::Repository::init(&output_dir).context("Failed to initialize git repository")?; - // Use handle_remote_add to add the remote with the filter let remote_add_args = RemoteAddArgs { name: "origin".to_string(), url: to_absolute_remote_url(&args.url)?, diff --git a/josh-cli/src/commands/cache.rs b/josh-cli/src/commands/cache.rs index 867908a0f..ab1b4eb24 100644 --- a/josh-cli/src/commands/cache.rs +++ b/josh-cli/src/commands/cache.rs @@ -1,4 +1,5 @@ use std::collections::HashSet; +use std::str::FromStr; use anyhow::Context; @@ -96,7 +97,7 @@ fn handle_cache_build(args: &CacheBuildArgs, transaction: &Transaction) -> anyho let mut steps = Vec::new(); let mut ok = true; for id_str in ids.iter().rev() { - match git2::Oid::from_str(id_str) + match gix_hash::ObjectId::from_str(id_str) .map_err(anyhow::Error::from) .and_then(|oid| from_tree(transaction, oid)) { @@ -184,7 +185,7 @@ fn handle_cache_build(args: &CacheBuildArgs, transaction: &Transaction) -> anyho let mut next_commits = Vec::new(); for (branch_name, filtered_oid) in filtered { - if filtered_oid == git2::Oid::ZERO_SHA1 { + if filtered_oid == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { continue; } let filtered_ref = diff --git a/josh-cli/src/commands/changes.rs b/josh-cli/src/commands/changes.rs index a9368f59e..4d9d3d89b 100644 --- a/josh-cli/src/commands/changes.rs +++ b/josh-cli/src/commands/changes.rs @@ -177,10 +177,12 @@ pub fn handle_show( println!("Subject: {}", subject); println!(); - // PORT: the per-file line stats come out of libgit2's patch machinery; the change - // commit is behind a ref, so it is on disk. + // Line statistics still use libgit2's patch machinery. let repo = transaction.git2_repo(); - let files = file_stats(repo, &repo.find_commit(change.commit())?)?; + let files = file_stats( + repo, + &repo.find_commit(josh_core::objects::git2_oid(&change.commit()))?, + )?; let total_adds: usize = files.iter().map(|f| f.adds).sum(); let total_dels: usize = files.iter().map(|f| f.dels).sum(); println!( diff --git a/josh-cli/src/commands/link.rs b/josh-cli/src/commands/link.rs index 226f89928..106b3dd00 100644 --- a/josh-cli/src/commands/link.rs +++ b/josh-cli/src/commands/link.rs @@ -1,4 +1,5 @@ use anyhow::{Context, anyhow}; +use std::str::FromStr; use josh_link::make_signature; @@ -117,7 +118,7 @@ fn handle_link_add( .context("Failed to apply export filter")?; // If the export filter found no local content, fall back to fetching the remote. - let initial_oid = if export_oid != git2::Oid::ZERO_SHA1 { + let initial_oid = if export_oid != gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { eprintln!( "Using local content at '{}' ({})", normalized_path, export_oid @@ -133,14 +134,14 @@ fn handle_link_add( .spawn_git(&["fetch", &args.url, target], &[]) .context("Failed to execute git fetch")?; - // PORT: FETCH_HEAD pseudo-ref read stays on the git2 handle until flag day - // (gix multi-entry FETCH_HEAD semantics still unresolved). - let fetched_oid = repo - .find_reference("FETCH_HEAD") - .context("Failed to find FETCH_HEAD after fetch")? - .peel_to_commit() - .context("Failed to peel FETCH_HEAD to commit")? - .id(); + // Preserve libgit2's multi-entry FETCH_HEAD resolution. + let fetched_oid = josh_core::objects::gix_oid( + repo.find_reference("FETCH_HEAD") + .context("Failed to find FETCH_HEAD after fetch")? + .peel_to_commit() + .context("Failed to peel FETCH_HEAD to commit")? + .id(), + ); eprintln!("Using fetched commit {}", fetched_oid); fetched_oid @@ -223,10 +224,10 @@ fn handle_link_fetch( let mut skipped = 0; for link_ref in &link_refs { - let oid = git2::Oid::from_str(&link_ref.commit) + let oid = gix_hash::ObjectId::from_str(&link_ref.commit) .with_context(|| format!("Invalid commit SHA in link file: {}", link_ref.commit))?; - if odb.exists(oid) { + if odb.exists(josh_core::objects::git2_oid(&oid)) { skipped += 1; continue; } @@ -280,7 +281,7 @@ fn handle_link_update( ); let filtered_oid = josh_core::filter_commit(transaction, roundtrip, head_commit) .context("Failed to apply filter")?; - if filtered_oid == git2::Oid::ZERO_SHA1 { + if filtered_oid == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { vec![] } else { let odb = transaction.odb(); @@ -320,14 +321,14 @@ fn handle_link_update( .spawn_git(&["fetch", &remote, &branch], &[]) .with_context(|| format!("git fetch failed for '{}'", path.display()))?; - // PORT: FETCH_HEAD pseudo-ref read stays on the git2 handle until flag day - // (gix multi-entry FETCH_HEAD semantics still unresolved). - let new_oid = repo - .find_reference("FETCH_HEAD") - .context("Failed to find FETCH_HEAD")? - .peel_to_commit() - .context("Failed to get FETCH_HEAD commit")? - .id(); + // Preserve libgit2's multi-entry FETCH_HEAD resolution. + let new_oid = josh_core::objects::gix_oid( + repo.find_reference("FETCH_HEAD") + .context("Failed to find FETCH_HEAD")? + .peel_to_commit() + .context("Failed to get FETCH_HEAD commit")? + .id(), + ); links_to_update.push((path.clone(), new_oid)); } @@ -397,7 +398,7 @@ fn handle_link_push( let exported_commit = josh_core::filter_commit(transaction, combined_filter, head_commit) .context("Failed to apply export filter")?; - if exported_commit == git2::Oid::ZERO_SHA1 { + if exported_commit == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { return Err(anyhow!("No content found at path '{}' to push", args.path)); } diff --git a/josh-cli/src/commands/pull.rs b/josh-cli/src/commands/pull.rs index 9a0c4f2ff..bd7374f63 100644 --- a/josh-cli/src/commands/pull.rs +++ b/josh-cli/src/commands/pull.rs @@ -39,7 +39,7 @@ pub enum IntegrateReport { }, } -fn short_oid(oid: git2::Oid) -> String { +fn short_oid(oid: gix_hash::ObjectId) -> String { oid.to_string()[..7].to_string() } @@ -47,7 +47,10 @@ fn short_oid(oid: git2::Oid) -> String { /// Used as an opportunistic content hash: a change that was merely restacked /// (rebased without content edits) keeps its patch-id even though the commit /// and tree oids all change. -fn patch_id(transaction: &josh_core::cache::Transaction, oid: git2::Oid) -> anyhow::Result { +fn patch_id( + transaction: &josh_core::cache::Transaction, + oid: gix_hash::ObjectId, +) -> anyhow::Result { let output = transaction .git_command( &[ @@ -95,8 +98,8 @@ fn patch_id(transaction: &josh_core::cache::Transaction, oid: git2::Oid) -> anyh /// changed). Without a transaction (tests), every update counts as changed. fn change_content_changed( transaction: Option<&josh_core::cache::Transaction>, - old: git2::Oid, - new: git2::Oid, + old: gix_hash::ObjectId, + new: gix_hash::ObjectId, ) -> bool { let Some(transaction) = transaction else { return true; @@ -228,20 +231,24 @@ fn resolve_upstream_ref( /// commits (e.g. by a merge queue) are detected as well. fn upstream_change_ids( transaction: &josh_core::cache::Transaction, - tip: git2::Oid, - base: git2::Oid, + tip: gix_hash::ObjectId, + base: gix_hash::ObjectId, ) -> anyhow::Result> { - let repo = transaction.git2_repo(); let odb = transaction.odb(); - let mut ids = std::collections::HashSet::new(); - let mut walk = repo.revwalk()?; - walk.push(tip)?; - if base != git2::Oid::ZERO_SHA1 { - walk.hide(base)?; - } + let commits = if base.is_null() { + let mut walk = josh_core::objects::RevWalk::new(odb); + walk.push(tip)?; + walk.into_topo_vec(|_| false)? + } else { + josh_core::objects::RangeWalk::new(odb, |oid| { + josh_core::cache::compute_sequence_number(transaction, oid) + }) + .into_topo_vec(tip, base)? + }; - for oid in walk { - let commit = josh_core::objects::CommitData::read(odb, oid?)?; + let mut ids = std::collections::HashSet::new(); + for oid in commits { + let commit = josh_core::objects::CommitData::read(odb, oid)?; if let (Some(id), _) = commit_change_meta(&commit) { ids.insert(id); } @@ -254,9 +261,9 @@ fn upstream_change_ids( /// Bails out on conflicts without writing any refs. fn restack_commits( transaction: &josh_core::cache::Transaction, - tip: git2::Oid, - commits: &[git2::Oid], -) -> anyhow::Result<(git2::Oid, usize)> { + tip: gix_hash::ObjectId, + commits: &[gix_hash::ObjectId], +) -> anyhow::Result<(gix_hash::ObjectId, usize)> { use josh_core::objects::CommitData; let odb = transaction.odb(); @@ -351,24 +358,21 @@ pub fn integrate( let (new_tip, report) = if merge_base == old { (new, IntegrateReport::FastForward { branch }) } else { - // Diverged: collect the local-only commits (linear stack expected). - let mut walk = repo.revwalk()?; - walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?; - walk.simplify_first_parent()?; + let odb = transaction.odb(); + let mut walk = josh_core::objects::RevWalk::new(odb); + walk.simplify_first_parent(); walk.push(old)?; - walk.hide(merge_base)?; + let mut local_commits = walk.into_topo_vec(|oid| oid == merge_base)?; + local_commits.reverse(); - let mut local_commits = Vec::new(); - for oid in walk { - let oid = oid?; - let commit = josh_core::objects::CommitData::read(transaction.odb(), oid)?; - if commit.parent_ids().count() > 1 { + for &oid in &local_commits { + let commit = josh_core::objects::CommitData::read(odb, oid)?; + if commit.parent_count() > 1 { anyhow::bail!( "local branch '{}' contains merge commits; cannot integrate automatically", branch ); } - local_commits.push(oid); } let applied = upstream_change_ids(transaction, new, merge_base)?; @@ -379,11 +383,7 @@ pub fn integrate( let commit = josh_core::objects::CommitData::read(transaction.odb(), oid)?; match commit_change_meta(&commit) { (Some(id), _) if applied.contains(&id) => skipped.push(id), - // A commit with a Change-Id not yet applied upstream, or one - // without a Change-Id at all, is a local-only commit we keep and - // restack. A missing Change-Id just means we cannot match it - // against the upstream stack; restacking cherry-picks it on top - // and drops it if its content already landed (becomes empty). + // Keep unmatched commits; restacking drops content that already landed. _ => remaining.push(oid), } } @@ -409,7 +409,7 @@ pub fn integrate( // Update the worktree first (safe: refuses to clobber conflicting local // modifications), only then move the branch ref. - let new_tip_commit = repo.find_commit(new_tip)?; + let new_tip_commit = repo.find_commit(josh_core::objects::git2_oid(&new_tip))?; repo.checkout_tree( new_tip_commit.as_object(), Some(git2::build::CheckoutBuilder::new().safe()), @@ -529,12 +529,13 @@ pub fn handle_pull( #[cfg(test)] mod tests { use super::*; + use std::str::FromStr; const OID_A: &str = "af180e6da554e60815593af48d419ac0e719c47a"; const OID_B: &str = "2bf1cefd82c96e5d7478ff834c59194d40e539c4"; - fn oid(s: &str) -> git2::Oid { - git2::Oid::from_str(s).unwrap() + fn oid(s: &str) -> gix_hash::ObjectId { + gix_hash::ObjectId::from_str(s).unwrap() } fn fast_forward(old: &str, new: &str, reference: &str) -> RefUpdate { @@ -552,6 +553,46 @@ mod tests { } } + #[test] + fn upstream_change_ids_walks_every_parent_between_base_and_tip() { + let dir = tempfile::tempdir().unwrap(); + git2::Repository::init_bare(dir.path()).unwrap(); + let context = josh_core::cache::TransactionContext::new( + dir.path(), + std::sync::Arc::new(josh_core::cache::CacheStack::new()), + ); + let transaction = context.open().unwrap(); + let signature = + git2::Signature::new("Test", "test@example.com", &git2::Time::new(0, 0)).unwrap(); + let commit = |parents: &[gix_hash::ObjectId], change: Option<&str>| { + let message = change + .map(|id| format!("Subject\n\nChange: {id}\n")) + .unwrap_or_else(|| "Root".to_string()); + josh_core::objects::write_commit( + transaction.odb(), + gix_hash::ObjectId::empty_tree(gix_hash::Kind::Sha1), + parents, + &signature, + &signature, + &message, + ) + .unwrap() + }; + + let root = commit(&[], None); + let left = commit(&[root], Some("left")); + let right = commit(&[root], Some("right")); + let merge = commit(&[left, right], Some("merge")); + + assert_eq!( + upstream_change_ids(&transaction, merge, root).unwrap(), + ["left", "right", "merge"] + .map(String::from) + .into_iter() + .collect() + ); + } + #[test] fn summary_renders_branch_updates() { let updates = vec![ diff --git a/josh-cli/src/commands/push.rs b/josh-cli/src/commands/push.rs index 58e5031b3..dfd35a2e9 100644 --- a/josh-cli/src/commands/push.rs +++ b/josh-cli/src/commands/push.rs @@ -142,12 +142,15 @@ fn prepare_push( let old_filtered = if let Some((_, filtered_oid)) = filtered_oids.first() { *filtered_oid } else { - git2::Oid::ZERO_SHA1 + gix_hash::ObjectId::null(gix_hash::Kind::Sha1) }; (dest_oid, old_filtered) } else { - (git2::Oid::ZERO_SHA1, git2::Oid::ZERO_SHA1) + ( + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + ) }; let original_target = if let Some(base) = base { @@ -180,7 +183,7 @@ fn prepare_push( .context("Failed to unapply filter")?; let unfiltered_oid = if merge { - if original_target == git2::Oid::ZERO_SHA1 { + if original_target == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { return Err(anyhow!( "--merge requires --base= or an existing destination ref" )); diff --git a/josh-cli/src/commands/run.rs b/josh-cli/src/commands/run.rs index d4bf40306..78b550359 100644 --- a/josh-cli/src/commands/run.rs +++ b/josh-cli/src/commands/run.rs @@ -21,6 +21,13 @@ pub fn handle_compose( args: &ComposeArgs, transaction: &josh_core::cache::Transaction, ) -> anyhow::Result<()> { + #[cfg(windows)] + { + let _ = (args, transaction); + anyhow::bail!("josh compose is not supported on Windows"); + } + + #[cfg(not(windows))] match &args.command { ComposeCommand::Run(run_args) => handle_run(run_args, transaction), ComposeCommand::ListImages(list_args) => handle_list_images(list_args, transaction), diff --git a/josh-cli/src/commands/sync.rs b/josh-cli/src/commands/sync.rs index eed3f35e3..90b3f36d9 100644 --- a/josh-cli/src/commands/sync.rs +++ b/josh-cli/src/commands/sync.rs @@ -38,10 +38,10 @@ pub fn handle_sync( let base_oid = if let Some(b) = &branch { match transaction.resolve_ref(&format!("refs/remotes/origin/{}", b))? { Some(oid) => josh_core::objects::peel_to_commit(transaction.odb(), oid)?, - None => git2::Oid::ZERO_SHA1, + None => gix_hash::ObjectId::null(gix_hash::Kind::Sha1), } } else { - git2::Oid::ZERO_SHA1 + gix_hash::ObjectId::null(gix_hash::Kind::Sha1) }; let resolved = args.scope.resolve(transaction)?; @@ -93,8 +93,8 @@ fn sync_local( args: &SyncArgs, transaction: &josh_core::cache::Transaction, local_branch: &str, - head_oid: git2::Oid, - base_oid: git2::Oid, + head_oid: gix_hash::ObjectId, + base_oid: gix_hash::ObjectId, ) -> anyhow::Result<()> { if args.push { return Err(anyhow!( diff --git a/josh-cli/src/forge/github/changes.rs b/josh-cli/src/forge/github/changes.rs index 63f2c85ca..3df88f070 100644 --- a/josh-cli/src/forge/github/changes.rs +++ b/josh-cli/src/forge/github/changes.rs @@ -3,6 +3,7 @@ //! feedback back to GitHub. use anyhow::{Context, anyhow}; +use std::str::FromStr; use josh_core::git::normalize_repo_path; use josh_github_graphql::connection::GithubApiConnection; @@ -268,7 +269,7 @@ struct GithubSyncCtx<'a> { owner: &'a str, repo_name: &'a str, remote_name: &'a str, - target_branch_shas: &'a std::collections::HashMap, + target_branch_shas: &'a std::collections::HashMap, } impl GithubSyncCtx<'_> { @@ -279,14 +280,14 @@ impl GithubSyncCtx<'_> { let (existing_change_id, _) = josh_core::trailers::parse_change_meta(&pr.head_commit_message); - let head_oid = - git2::Oid::from_str(&pr.head_oid).map_err(|e| anyhow!("bad head OID: {}", e))?; + let head_oid = gix_hash::ObjectId::from_str(&pr.head_oid) + .map_err(|e| anyhow!("bad head OID: {}", e))?; let odb = self.transaction.odb(); josh_core::objects::CommitData::read(odb, head_oid) .map_err(|_| anyhow!("head commit {} not available from GitHub", pr.head_oid))?; - let base_oid = - git2::Oid::from_str(&pr.base_ref_oid).map_err(|e| anyhow!("bad base OID: {}", e))?; + let base_oid = gix_hash::ObjectId::from_str(&pr.base_ref_oid) + .map_err(|e| anyhow!("bad base OID: {}", e))?; josh_core::objects::CommitData::read(odb, base_oid) .map_err(|_| anyhow!("base commit {} not available from GitHub", pr.base_ref_oid))?; @@ -405,11 +406,11 @@ struct PrMeta { /// `{owner}/{repo}/pull/{N}` id. change_id: String, remote_scope: josh_changes::ChangesRef, - head: git2::Oid, + head: gix_hash::ObjectId, /// Immediate base commit of the PR. - target: git2::Oid, + target: gix_hash::ObjectId, /// Resolved change base for change-id'd heads; `None` for synthetic merges. - change_base: Option, + change_base: Option, pr_data: josh_github_graphql::operations::pull_request::PrData, } @@ -828,7 +829,7 @@ fn fetch_sync_objects( owner: &str, repo_name: &str, prs: &[PrSummary], -) -> anyhow::Result> { +) -> anyhow::Result> { const SCRATCH: &str = "refs/josh/sync-tips"; let github_url = format!("https://github.com/{}/{}", owner, repo_name); diff --git a/josh-cli/src/porcelain.rs b/josh-cli/src/porcelain.rs index 14154d3ab..e3e21f12d 100644 --- a/josh-cli/src/porcelain.rs +++ b/josh-cli/src/porcelain.rs @@ -2,26 +2,33 @@ //! //! Each updated ref is reported as one ` ` //! line; rejected updates may carry a `(reason)` suffix after the ref name. +use std::str::FromStr; /// One ref update reported by `git fetch --porcelain`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RefUpdate { /// Fast-forward update (' '). FastForward { - old: git2::Oid, - new: git2::Oid, + old: gix_hash::ObjectId, + new: gix_hash::ObjectId, reference: String, }, /// Forced (non-fast-forward) update ('+'). Forced { - old: git2::Oid, - new: git2::Oid, + old: gix_hash::ObjectId, + new: gix_hash::ObjectId, reference: String, }, /// Newly created ref ('*'). - New { new: git2::Oid, reference: String }, + New { + new: gix_hash::ObjectId, + reference: String, + }, /// Deleted ref ('-'). - Deleted { old: git2::Oid, reference: String }, + Deleted { + old: gix_hash::ObjectId, + reference: String, + }, /// Update rejected by the remote ('!'), with an optional reason. Rejected { reference: String, @@ -192,8 +199,8 @@ pub fn parse_fetch_porcelain(output: &str) -> anyhow::Result> { let new = parts.next().ok_or_else(|| parse_error(line))?; let reference = parts.next().ok_or_else(|| parse_error(line))?; - let old = git2::Oid::from_str(old).map_err(|_| parse_error(line))?; - let new = git2::Oid::from_str(new).map_err(|_| parse_error(line))?; + let old = gix_hash::ObjectId::from_str(old).map_err(|_| parse_error(line))?; + let new = gix_hash::ObjectId::from_str(new).map_err(|_| parse_error(line))?; let reference = reference.to_string(); let update = match flag { @@ -243,8 +250,8 @@ mod tests { const OID_A: &str = "af180e6da554e60815593af48d419ac0e719c47a"; const OID_B: &str = "2bf1cefd82c96e5d7478ff834c59194d40e539c4"; - fn oid(s: &str) -> git2::Oid { - git2::Oid::from_str(s).unwrap() + fn oid(s: &str) -> gix_hash::ObjectId { + gix_hash::ObjectId::from_str(s).unwrap() } #[test] diff --git a/josh-cli/src/remote_ops.rs b/josh-cli/src/remote_ops.rs index 24901f663..efa4a5cd3 100644 --- a/josh-cli/src/remote_ops.rs +++ b/josh-cli/src/remote_ops.rs @@ -73,7 +73,7 @@ pub fn resolve_default_branch( pub fn get_backing_refs( transaction: &josh_core::cache::Transaction, remote_name: &str, -) -> anyhow::Result> { +) -> anyhow::Result> { let mut input_refs = Vec::new(); transaction.for_each_ref_prefixed( &format!("refs/josh/remotes/{}/", remote_name), @@ -125,16 +125,17 @@ pub fn apply_josh_filtering( let steps = flatten_chain(filter); // Seed with the raw backing refs - let mut current_commits: Vec<(String, git2::Oid)> = get_backing_refs(transaction, remote_name)? - .into_iter() - .map(|(refname, oid)| { - let branch = refname - .strip_prefix(&prefix) - .unwrap_or(&refname) - .to_string(); - (branch, oid) - }) - .collect(); + let mut current_commits: Vec<(String, gix_hash::ObjectId)> = + get_backing_refs(transaction, remote_name)? + .into_iter() + .map(|(refname, oid)| { + let branch = refname + .strip_prefix(&prefix) + .unwrap_or(&refname) + .to_string(); + (branch, oid) + }) + .collect(); // Apply each step, writing filtered refs along the way for (step_idx, step_filter) in steps.iter().enumerate() { @@ -152,7 +153,7 @@ pub fn apply_josh_filtering( let mut next_commits = Vec::new(); for (branch_name, filtered_oid) in &filtered { - if *filtered_oid == git2::Oid::ZERO_SHA1 { + if *filtered_oid == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { continue; } diff --git a/josh-compose-podman/src/lib.rs b/josh-compose-podman/src/lib.rs index 24bddb6fc..69940b2d5 100644 --- a/josh-compose-podman/src/lib.rs +++ b/josh-compose-podman/src/lib.rs @@ -38,12 +38,19 @@ impl Default for PodmanRuntime { /// Host uid/gid of the invoking user — the identity container steps run as and /// artifacts are chowned to. This is a container mechanic; the scheduler never /// needs to know it. +#[cfg(unix)] fn host_uid_gid() -> (u32, u32) { let uid = unsafe { libc::getuid() }; let gid = unsafe { libc::getgid() }; (uid, gid) } +/// Unreachable: `josh compose` is refused on Windows before any container runs. +#[cfg(windows)] +fn host_uid_gid() -> (u32, u32) { + unreachable!("josh compose is not supported on Windows") +} + fn host_identity() -> String { let (uid, gid) = host_uid_gid(); format!("{uid}:{gid}") diff --git a/josh-compose/Cargo.toml b/josh-compose/Cargo.toml index 2e933b004..20dfcef73 100644 --- a/josh-compose/Cargo.toml +++ b/josh-compose/Cargo.toml @@ -12,7 +12,7 @@ version = "26.7.28" [dependencies] anyhow.workspace = true defer.workspace = true -git2.workspace = true +gix-hash.workspace = true josh-core.workspace = true josh-filter.workspace = true josh-compose-backend.workspace = true diff --git a/josh-compose/src/archive.rs b/josh-compose/src/archive.rs index eb89b7fa6..f93124d79 100644 --- a/josh-compose/src/archive.rs +++ b/josh-compose/src/archive.rs @@ -7,7 +7,7 @@ use josh_core::memodb; pub fn tree_to_tar( transaction: &cache::Transaction, odb: &memodb::Odb, - tree_oid: git2::Oid, + tree_oid: gix_hash::ObjectId, ) -> anyhow::Result> { let mut buf = Vec::new(); { @@ -21,7 +21,7 @@ pub fn tree_to_tar( fn append_tree( transaction: &cache::Transaction, odb: &memodb::Odb, - tree_oid: git2::Oid, + tree_oid: gix_hash::ObjectId, prefix: &str, builder: &mut tar::Builder, ) -> anyhow::Result<()> { @@ -33,7 +33,7 @@ fn append_tree( } else { format!("{prefix}/{name}") }; - let id = josh_core::objects::git2_oid(entry.oid); + let id = entry.oid.to_owned(); if entry.mode.is_link() { let content = tree::blob_bytes(odb, id) diff --git a/josh-compose/src/container.rs b/josh-compose/src/container.rs index dde0a6aee..b5ca7577e 100644 --- a/josh-compose/src/container.rs +++ b/josh-compose/src/container.rs @@ -1,5 +1,6 @@ use std::collections::HashSet; use std::path::Path; +use std::str::FromStr; use josh_core::cache; use josh_core::memodb; @@ -78,8 +79,8 @@ fn start_sidecar( pub fn run_container( transaction: &cache::Transaction, odb: &memodb::Odb, - ws_tree: git2::Oid, - attempted: &mut HashSet, + ws_tree: gix_hash::ObjectId, + attempted: &mut HashSet, extract_to_workdir: bool, runtime: &dyn Runtime, ) -> anyhow::Result<()> { @@ -117,7 +118,7 @@ pub fn run_container( let mut dep_volumes: Vec<(String, String, bool)> = vec![]; let mut dep_errors: Vec = vec![]; for (dep_name, dep_sha) in &input_entries { - let dep_tree = match git2::Oid::from_str(dep_sha.trim()) { + let dep_tree = match gix_hash::ObjectId::from_str(dep_sha.trim()) { Ok(oid) => oid, Err(_) => { dep_errors.push(format!("dependency {dep_name}: invalid SHA {dep_sha:?}")); diff --git a/josh-compose/src/filter.rs b/josh-compose/src/filter.rs index ee1c038e9..59206d1ac 100644 --- a/josh-compose/src/filter.rs +++ b/josh-compose/src/filter.rs @@ -5,7 +5,7 @@ use anyhow::Context; pub fn resolve_input( transaction: &josh_core::cache::Transaction, input_ref: &str, -) -> anyhow::Result { +) -> anyhow::Result { josh_core::git::resolve_snapshot_input(transaction, input_ref) .with_context(|| format!("failed to resolve input ref: {input_ref:?}")) } @@ -18,8 +18,8 @@ pub fn resolve_input( pub fn compute_ws_tree( transaction: &josh_core::cache::Transaction, filter_spec: &str, - source_commit: git2::Oid, -) -> anyhow::Result<(git2::Oid, String)> { + source_commit: gix_hash::ObjectId, +) -> anyhow::Result<(gix_hash::ObjectId, String)> { let full_filter = format!(":SQUASH{filter_spec}"); let filterobj = josh_core::filter::parse(&full_filter) diff --git a/josh-compose/src/image.rs b/josh-compose/src/image.rs index e830fc40f..4443f2724 100644 --- a/josh-compose/src/image.rs +++ b/josh-compose/src/image.rs @@ -1,10 +1,10 @@ use anyhow::Context; +use std::str::FromStr; use josh_compose_backend::{EnvRecipe, EnvironmentBackend}; use josh_core::cache; use josh_core::filter::tree; use josh_core::memodb; -use josh_core::objects; use crate::meta; use crate::naming; @@ -14,7 +14,7 @@ use crate::naming; pub fn ensure_image( transaction: &cache::Transaction, odb: &memodb::Odb, - build_tree: git2::Oid, + build_tree: gix_hash::ObjectId, runtime: &dyn EnvironmentBackend, ) -> anyhow::Result { let image_name = naming::env(build_tree); @@ -32,7 +32,7 @@ pub fn ensure_image( // Containerfile can reference it (e.g. ARG my_base; FROM $my_base). let base_entries = meta::read_blob_entries(transaction, odb, build_tree, "bases"); for (base_name, base_sha) in &base_entries { - let base_oid = git2::Oid::from_str(base_sha.trim()) + let base_oid = gix_hash::ObjectId::from_str(base_sha.trim()) .with_context(|| format!("invalid base SHA for {base_name}: {base_sha}"))?; let base_env = ensure_image(transaction, odb, base_oid, runtime)?; build_args.push((base_name.clone(), base_env)); @@ -44,7 +44,7 @@ pub fn ensure_image( let context_entry = tree::read_tree(transaction, odb, build_tree)? .entry(b"context") - .map(|e| objects::git2_oid(e.oid)) + .map(|e| e.oid.to_owned()) .context("workspace image tree missing 'context' subtree")?; let context = crate::archive::tree_to_tar(transaction, odb, context_entry)?; diff --git a/josh-compose/src/lib.rs b/josh-compose/src/lib.rs index ed022ad35..ea4e2d3ae 100644 --- a/josh-compose/src/lib.rs +++ b/josh-compose/src/lib.rs @@ -86,7 +86,7 @@ pub fn plan_images( opts: RunOptions, ignore_cache: bool, runtime: &dyn ArtifactBackend, -) -> anyhow::Result> { +) -> anyhow::Result> { josh_filter::check_experimental_features_enabled("josh compose images")?; let filter_spec = opts.filter_spec.trim().to_string(); @@ -110,7 +110,7 @@ pub fn plan_jobs( opts: RunOptions, ignore_cache: bool, runtime: &dyn ArtifactBackend, -) -> anyhow::Result> { +) -> anyhow::Result> { josh_filter::check_experimental_features_enabled("josh compose jobs")?; let filter_spec = opts.filter_spec.trim().to_string(); diff --git a/josh-compose/src/meta.rs b/josh-compose/src/meta.rs index 842772e5a..530e41c87 100644 --- a/josh-compose/src/meta.rs +++ b/josh-compose/src/meta.rs @@ -6,13 +6,13 @@ //! scheduler. use std::path::Path; +use std::str::FromStr; use crate::OutputMode; use josh_compose_backend::NetworkPolicy; use josh_core::cache; use josh_core::filter::tree; use josh_core::memodb; -use josh_core::objects; /// Specification for a sidecar service that runs alongside a workspace step. /// @@ -22,7 +22,7 @@ pub struct SidecarSpec { /// Logical name used for addressing and labeling. pub name: String, /// Build-tree OID of the image to run. - pub image: git2::Oid, + pub image: gix_hash::ObjectId, /// Static environment variables set by the workspace config. pub env: Vec<(String, String)>, /// Environment variable names to forward from the host process (e.g. API keys, CI @@ -45,10 +45,10 @@ pub struct WorkspaceMeta { pub cache: Option, pub network: NetworkPolicy, /// Tree OID of the image workspace. `None` for orchestrator-only workspaces. - pub image: Option, + pub image: Option, /// Tree OID of the workspace files mounted into the environment at `/worktree`. /// `None` for orchestrator-only workspaces. - pub worktree: Option, + pub worktree: Option, pub sidecars: Vec, } @@ -56,11 +56,11 @@ pub struct WorkspaceMeta { pub fn read_blob( transaction: &cache::Transaction, odb: &memodb::Odb, - tree_oid: git2::Oid, + tree_oid: gix_hash::ObjectId, path: &str, ) -> Option { let entry = tree::get_path_entry(transaction, odb, tree_oid, Path::new(path)).ok()??; - let content = tree::blob_bytes(odb, objects::git2_oid(&entry.oid))?; + let content = tree::blob_bytes(odb, entry.oid.to_owned())?; Some(std::str::from_utf8(&content).ok()?.trim().to_string()) } @@ -68,14 +68,14 @@ pub fn read_blob( pub fn read_tree_entries( transaction: &cache::Transaction, odb: &memodb::Odb, - tree_oid: git2::Oid, + tree_oid: gix_hash::ObjectId, prefix: &str, -) -> Vec<(String, git2::Oid)> { +) -> Vec<(String, gix_hash::ObjectId)> { let Ok(Some(entry)) = tree::get_path_entry(transaction, odb, tree_oid, Path::new(prefix)) else { return vec![]; }; - let Ok(subtree) = tree::read_tree(transaction, odb, objects::git2_oid(&entry.oid)) else { + let Ok(subtree) = tree::read_tree(transaction, odb, entry.oid.to_owned()) else { return vec![]; }; subtree @@ -83,7 +83,7 @@ pub fn read_tree_entries( .map(|e| { ( String::from_utf8_lossy(e.filename).into_owned(), - objects::git2_oid(e.oid), + e.oid.to_owned(), ) }) .collect() @@ -93,7 +93,7 @@ pub fn read_tree_entries( pub fn read_blob_entries( transaction: &cache::Transaction, odb: &memodb::Odb, - tree_oid: git2::Oid, + tree_oid: gix_hash::ObjectId, prefix: &str, ) -> Vec<(String, String)> { read_tree_entries(transaction, odb, tree_oid, prefix) @@ -113,7 +113,7 @@ pub fn read_blob_entries( pub fn read_meta( transaction: &cache::Transaction, odb: &memodb::Odb, - ws_tree: git2::Oid, + ws_tree: gix_hash::ObjectId, ) -> anyhow::Result { let label = read_blob(transaction, odb, ws_tree, "label") .filter(|s| !s.is_empty()) @@ -139,13 +139,13 @@ pub fn read_meta( let image = read_blob(transaction, odb, ws_tree, "image") .filter(|s| !s.is_empty()) .map(|sha| { - git2::Oid::from_str(&sha) + gix_hash::ObjectId::from_str(&sha) .map_err(|_| anyhow::anyhow!("invalid image SHA in workspace tree: {sha}")) }) .transpose()?; let tree = tree::read_tree(transaction, odb, ws_tree)?; - let worktree = tree.entry(b"worktree").map(|e| objects::git2_oid(e.oid)); + let worktree = tree.entry(b"worktree").map(|e| e.oid.to_owned()); let sidecars = read_sidecars(transaction, odb, ws_tree)?; @@ -164,16 +164,16 @@ pub fn read_meta( pub fn read_sidecars( transaction: &cache::Transaction, odb: &memodb::Odb, - ws_tree: git2::Oid, + ws_tree: gix_hash::ObjectId, ) -> anyhow::Result> { let mut out = vec![]; for (name, content) in read_blob_entries(transaction, odb, ws_tree, "sidecars") { - let sidecar_tree = git2::Oid::from_str(content.trim()) + let sidecar_tree = gix_hash::ObjectId::from_str(content.trim()) .map_err(|_| anyhow::anyhow!("sidecar {name}: invalid tree SHA {content:?}"))?; let image_sha = read_blob(transaction, odb, sidecar_tree, "image") .filter(|s| !s.is_empty()) .ok_or_else(|| anyhow::anyhow!("sidecar {name}: missing image"))?; - let image = git2::Oid::from_str(&image_sha) + let image = gix_hash::ObjectId::from_str(&image_sha) .map_err(|_| anyhow::anyhow!("sidecar {name}: invalid image SHA {image_sha:?}"))?; let port_str = read_blob(transaction, odb, sidecar_tree, "port") .filter(|s| !s.is_empty()) diff --git a/josh-compose/src/naming.rs b/josh-compose/src/naming.rs index 8d4a3ab70..af590dd3d 100644 --- a/josh-compose/src/naming.rs +++ b/josh-compose/src/naming.rs @@ -10,7 +10,7 @@ //! unambiguous and don't collide with anything else on the system. /// Output artifact for the workspace tree `ws_tree` (mounted at `/out`). -pub fn output(ws_tree: git2::Oid) -> String { +pub fn output(ws_tree: gix_hash::ObjectId) -> String { format!("{OUTPUT_PREFIX}{ws_tree}") } @@ -20,7 +20,7 @@ pub fn cache(cache_name: &str) -> String { } /// Environment key for the image built from `build_tree`. -pub fn env(build_tree: git2::Oid) -> String { +pub fn env(build_tree: gix_hash::ObjectId) -> String { format!("{ENV_PREFIX}{build_tree}") } diff --git a/josh-compose/src/plan.rs b/josh-compose/src/plan.rs index 625c4a375..817cc2a1b 100644 --- a/josh-compose/src/plan.rs +++ b/josh-compose/src/plan.rs @@ -1,4 +1,5 @@ use std::collections::HashSet; +use std::str::FromStr; use josh_core::cache; use josh_core::memodb; @@ -23,13 +24,13 @@ use crate::naming; pub fn collect_image_oids( transaction: &cache::Transaction, odb: &memodb::Odb, - ws_tree: git2::Oid, + ws_tree: gix_hash::ObjectId, ignore_cache: bool, runtime: &dyn ArtifactBackend, -) -> anyhow::Result> { - let mut out: Vec = vec![]; - let mut image_seen: HashSet = HashSet::new(); - let mut ws_seen: HashSet = HashSet::new(); +) -> anyhow::Result> { + let mut out: Vec = vec![]; + let mut image_seen: HashSet = HashSet::new(); + let mut ws_seen: HashSet = HashSet::new(); walk_workspace( transaction, odb, @@ -54,12 +55,12 @@ pub fn collect_image_oids( pub fn collect_job_hashes( transaction: &cache::Transaction, odb: &memodb::Odb, - ws_tree: git2::Oid, + ws_tree: gix_hash::ObjectId, ignore_cache: bool, runtime: &dyn ArtifactBackend, -) -> anyhow::Result> { - let mut out: Vec = vec![]; - let mut ws_seen: HashSet = HashSet::new(); +) -> anyhow::Result> { + let mut out: Vec = vec![]; + let mut ws_seen: HashSet = HashSet::new(); walk_workspace_jobs( transaction, odb, @@ -75,12 +76,12 @@ pub fn collect_job_hashes( fn walk_workspace( transaction: &cache::Transaction, odb: &memodb::Odb, - ws_tree: git2::Oid, + ws_tree: gix_hash::ObjectId, ignore_cache: bool, runtime: &dyn ArtifactBackend, - out: &mut Vec, - image_seen: &mut HashSet, - ws_seen: &mut HashSet, + out: &mut Vec, + image_seen: &mut HashSet, + ws_seen: &mut HashSet, ) -> anyhow::Result<()> { if !ws_seen.insert(ws_tree) { return Ok(()); @@ -97,7 +98,7 @@ fn walk_workspace( } for (dep_name, dep_sha) in meta::read_blob_entries(transaction, odb, ws_tree, "inputs") { - let dep_tree = git2::Oid::from_str(dep_sha.trim()) + let dep_tree = gix_hash::ObjectId::from_str(dep_sha.trim()) .map_err(|_| anyhow::anyhow!("dependency {dep_name}: invalid tree SHA {dep_sha:?}"))?; walk_workspace( transaction, @@ -124,11 +125,11 @@ fn walk_workspace( fn walk_workspace_jobs( transaction: &cache::Transaction, odb: &memodb::Odb, - ws_tree: git2::Oid, + ws_tree: gix_hash::ObjectId, ignore_cache: bool, runtime: &dyn ArtifactBackend, - out: &mut Vec, - ws_seen: &mut HashSet, + out: &mut Vec, + ws_seen: &mut HashSet, ) -> anyhow::Result<()> { if !ws_seen.insert(ws_tree) { return Ok(()); @@ -141,7 +142,7 @@ fn walk_workspace_jobs( } for (dep_name, dep_sha) in meta::read_blob_entries(transaction, odb, ws_tree, "inputs") { - let dep_tree = git2::Oid::from_str(dep_sha.trim()) + let dep_tree = gix_hash::ObjectId::from_str(dep_sha.trim()) .map_err(|_| anyhow::anyhow!("dependency {dep_name}: invalid tree SHA {dep_sha:?}"))?; walk_workspace_jobs( transaction, @@ -162,7 +163,7 @@ fn walk_workspace_jobs( /// require the output volume when the workspace produces one. A skippable workspace /// won't be executed by a run, so its image and sidecar images are not needed. fn workspace_is_skippable( - ws_tree: git2::Oid, + ws_tree: gix_hash::ObjectId, meta: &WorkspaceMeta, runtime: &dyn ArtifactBackend, ) -> anyhow::Result { @@ -185,16 +186,16 @@ fn workspace_is_skippable( fn collect_image_with_bases( transaction: &cache::Transaction, odb: &memodb::Odb, - image_oid: git2::Oid, - out: &mut Vec, - image_seen: &mut HashSet, + image_oid: gix_hash::ObjectId, + out: &mut Vec, + image_seen: &mut HashSet, ) -> anyhow::Result<()> { if image_seen.contains(&image_oid) { return Ok(()); } for (base_name, base_sha) in meta::read_blob_entries(transaction, odb, image_oid, "bases") { - let base_oid = git2::Oid::from_str(base_sha.trim()) + let base_oid = gix_hash::ObjectId::from_str(base_sha.trim()) .map_err(|_| anyhow::anyhow!("invalid base SHA for {base_name}: {base_sha:?}"))?; collect_image_with_bases(transaction, odb, base_oid, out, image_seen)?; } diff --git a/josh-core/benches/deephistory_glob.rs b/josh-core/benches/deephistory_glob.rs index 8613b1e32..65528967c 100644 --- a/josh-core/benches/deephistory_glob.rs +++ b/josh-core/benches/deephistory_glob.rs @@ -6,6 +6,7 @@ use josh_test_support::bench::{ }; use rand::prelude::*; use std::path::{Path, PathBuf}; +use std::str::FromStr; // The scaling parameter of this benchmark is history *length*, not tree width. `filter_commit` // walks and rewrites a commit's whole ancestry, so applying a filter to the head does O(history) @@ -90,7 +91,7 @@ const JOSH_BENCH_COMMIT_TIME: &str = "1700000000"; /// One history length and the head of its generated history. struct SizeCase { n_commits: usize, - head: git2::Oid, + head: gix_hash::ObjectId, } struct GlobBench { @@ -119,7 +120,8 @@ impl GlobBench { // stamp checked against `EXPECTED_HEAD`. let provisioned = josh_test_support::provision_repo::provision_repo( CACHE_NAME, - &git2::Oid::from_str(EXPECTED_HEAD).expect("EXPECTED_HEAD must be a valid oid"), + &gix_hash::ObjectId::from_str(EXPECTED_HEAD) + .expect("EXPECTED_HEAD must be a valid oid"), |repo| { let mut heads = vec![]; for &n_commits in HISTORY_SIZES { @@ -138,7 +140,10 @@ impl GlobBench { let repo = &provisioned.repo; for &n_commits in HISTORY_SIZES { let head = repo.refname_to_id(&format!("refs/heads/case_{n_commits}"))?; - cases.push(SizeCase { n_commits, head }); + cases.push(SizeCase { + n_commits, + head: gix_oid(head), + }); } } @@ -166,7 +171,7 @@ impl GlobBench { let repo = transaction.git2_repo(); // The tripwire only means something if the dotfiles actually exist in the raw tree. - let raw_tree = repo.find_commit(case.head)?.tree()?; + let raw_tree = repo.find_commit(git2_oid(case.head))?.tree()?; for path in ["dir_01/.hidden.rs", "dir_01/.hiddendir/inner.rs"] { anyhow::ensure!( raw_tree.get_path(Path::new(path)).is_ok(), @@ -180,14 +185,14 @@ impl GlobBench { // The gate compares against an independent git2 reference model, which only // sees what is on disk. transaction.flush_mem_odb()?; - let got = repo.find_commit(filtered)?.tree_id(); + let got = repo.find_commit(git2_oid(filtered))?.tree_id(); let (want, kept) = expected_tree(repo, case.head, &glob_pred(pattern))?; anyhow::ensure!( kept > 0, "`::{pattern}` gate kept no blobs -- benchmark would be a no-op" ); anyhow::ensure!( - got == want, + gix_oid(got) == want, "`::{pattern}` produced {got}, expected {want} -- wrong measurement" ); } @@ -198,7 +203,7 @@ impl GlobBench { // an identity/subtree fast path must reproduce bit-identically. let filter = Filter::new().pattern(PATTERN_PREFIX).expect("valid glob"); let filtered = josh_core::filter_commit(&transaction, filter, case.head)?; - let got_tree = repo.find_commit(filtered)?.tree()?; + let got_tree = repo.find_commit(git2_oid(filtered))?.tree()?; anyhow::ensure!( got_tree.len() == 1, "`::{PATTERN_PREFIX}` result must have exactly one top-level entry" @@ -245,7 +250,7 @@ fn glob_pred(pattern: &str) -> impl Fn(&str) -> bool { /// directories, then generate an `n_commits` history that churns ~`CHURN_FRACTION` of the files /// per commit. The tip is tagged with `refs/heads/case_` so the head is recoverable /// after the repo round-trips through the cache. -fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result { +fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result { use rand::RngExt; let gix_repo = gix::open(repo.path())?; @@ -333,7 +338,7 @@ fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result anyhow::Result { - let parent = repo.find_commit(parent)?; +) -> anyhow::Result { + let parent = repo.find_commit(git2_oid(parent))?; let tree = parent.tree()?; let gix_repo = gix::open(repo.path())?; let mut builder = gix_repo.edit_tree(gix_oid(tree.id()))?; @@ -376,14 +381,14 @@ fn make_edit_commit( } let new_tree = repo.find_tree(git2_oid(builder.write()?.detach()))?; let sig = josh_commit_signature()?; - Ok(repo.commit( + Ok(gix_oid(repo.commit( None, &sig, &sig, &format!("local edit {n}"), &new_tree, &[&parent], - )?) + )?)) } fn deephistory_glob(c: &mut Criterion) { diff --git a/josh-core/benches/deephistory_prefix_flush.rs b/josh-core/benches/deephistory_prefix_flush.rs index 844130b61..0dcd8d8a0 100644 --- a/josh-core/benches/deephistory_prefix_flush.rs +++ b/josh-core/benches/deephistory_prefix_flush.rs @@ -4,6 +4,7 @@ use josh_core::git::josh_commit_signature; use josh_test_support::bench::{EntryKind, build_index, git2_oid, gix_oid, random_string}; use rand::prelude::*; use std::path::{Path, PathBuf}; +use std::str::FromStr; // This bench measures the object *write* path: unlike `:/` (which only selects existing // subtrees, so the filtered output reuses existing tree objects), a `:prefix=` filter writes @@ -71,7 +72,7 @@ const JOSH_BENCH_COMMIT_TIME: &str = "1700000000"; /// One history length and the head of its generated history. struct SizeCase { n_commits: usize, - head: git2::Oid, + head: gix_hash::ObjectId, } struct PrefixFlushBench { @@ -101,7 +102,8 @@ impl PrefixFlushBench { // stamp checked against `EXPECTED_HEAD`. let provisioned = josh_test_support::provision_repo::provision_repo( CACHE_NAME, - &git2::Oid::from_str(EXPECTED_HEAD).expect("EXPECTED_HEAD must be a valid oid"), + &gix_hash::ObjectId::from_str(EXPECTED_HEAD) + .expect("EXPECTED_HEAD must be a valid oid"), |repo| { let mut heads = vec![]; for &n_commits in HISTORY_SIZES { @@ -120,7 +122,10 @@ impl PrefixFlushBench { let repo = &provisioned.repo; for &n_commits in HISTORY_SIZES { let head = repo.refname_to_id(&format!("refs/heads/case_{n_commits}"))?; - cases.push(SizeCase { n_commits, head }); + cases.push(SizeCase { + n_commits, + head: gix_oid(head), + }); } } @@ -148,7 +153,7 @@ impl PrefixFlushBench { let filtered_tree = josh_core::objects::CommitData::read(odb, filtered)?.tree_id()?; let nested_tree = josh_core::objects::path_entry(odb, filtered_tree, Path::new(PREFIX))? - .map(|entry| josh_core::objects::git2_oid(&entry.oid)) + .map(|entry| entry.oid) .ok_or_else(|| anyhow::anyhow!("prefix filter produced no `{PREFIX}` entry"))?; let raw_tree = josh_core::objects::CommitData::read(odb, case.head)?.tree_id()?; anyhow::ensure!( @@ -172,7 +177,7 @@ impl PrefixFlushBench { /// directories, then generate an `n_commits` history that churns ~`CHURN_FRACTION` of the files per /// commit. The tip is tagged with `refs/heads/case_` so the head is recoverable after /// the repo round-trips through the cache. -fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result { +fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result { use rand::RngExt; let gix_repo = gix::open(repo.path())?; @@ -240,7 +245,7 @@ fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result String { /// directories, then generate an `n_commits` history that churns ~`CHURN_FRACTION` of the files per /// commit. The tip is tagged with `refs/heads/case_` so the head is recoverable after the /// repo round-trips through the cache. -fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result { +fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result { use rand::RngExt; // Deterministic root tree: file `i` lives at `dir_{i % N_DIRS}/file_{i}`, so files are spread @@ -231,18 +236,21 @@ fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result anyhow::Result { +fn build_index( + repo: &git2::Repository, + heads: &[gix_hash::ObjectId], +) -> anyhow::Result { let sig = josh_commit_signature()?; let empty_tree = repo.find_tree(repo.treebuilder(None)?.write()?)?; let parents = heads .iter() - .map(|oid| repo.find_commit(*oid)) + .map(|oid| repo.find_commit(git2_oid(*oid))) .collect::, _>>()?; let parent_refs = parents.iter().collect::>(); let index = repo.commit( @@ -253,7 +261,7 @@ fn build_index(repo: &git2::Repository, heads: &[git2::Oid]) -> anyhow::Result anyhow::Result { +fn count_history(repo: &git2::Repository, head: gix_hash::ObjectId) -> anyhow::Result { let mut walk = repo.revwalk()?; - walk.push(head)?; + walk.push(git2_oid(head))?; Ok(walk.count()) } @@ -181,7 +186,7 @@ fn random_string(rng: &mut StdRng, len: usize) -> String { /// `CHURN_PER_COMMIT` files outside it, and only with probability `SUBDIR_CHANGE_PROB` also touches a /// `dir_00` file. The tip is tagged `refs/heads/case_` so the head survives the cache /// round-trip. -fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result { +fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result { use rand::RngExt; // Deterministic root tree: file `i` lives at `dir_{i % N_DIRS}/file_{i}`; split the paths into the @@ -262,27 +267,30 @@ fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result anyhow::Result { +fn build_index( + repo: &git2::Repository, + heads: &[gix_hash::ObjectId], +) -> anyhow::Result { let sig = josh_commit_signature()?; let empty_tree = repo.find_tree(repo.treebuilder(None)?.write()?)?; let parents = heads .iter() - .map(|oid| repo.find_commit(*oid)) + .map(|oid| repo.find_commit(git2_oid(*oid))) .collect::, _>>()?; let parent_refs = parents.iter().collect::>(); - Ok(repo.commit( + Ok(gix_oid(repo.commit( Some("refs/heads/bench-index"), &sig, &sig, "bench index", &empty_tree, &parent_refs, - )?) + )?)) } fn deephistory_subdir_distributed(c: &mut Criterion) { diff --git a/josh-core/benches/deephistory_subdir_sparse.rs b/josh-core/benches/deephistory_subdir_sparse.rs index 01319c5eb..64144ecba 100644 --- a/josh-core/benches/deephistory_subdir_sparse.rs +++ b/josh-core/benches/deephistory_subdir_sparse.rs @@ -4,6 +4,7 @@ use josh_core::git::josh_commit_signature; use josh_test_support::bench::{EntryKind, git2_oid, gix_oid}; use rand::prelude::*; use std::path::{Path, PathBuf}; +use std::str::FromStr; // The sparse counterpart to the `deephistory_subdir` bench. Both apply a plain `:/dir_00` subdir // filter to the head of a long, fixed-width history, but this one makes the extracted subdir change @@ -54,7 +55,7 @@ const JOSH_BENCH_COMMIT_TIME: &str = "1700000000"; /// One history length and the head of its generated history. struct SizeCase { n_commits: usize, - head: git2::Oid, + head: gix_hash::ObjectId, } struct SubdirBench { @@ -80,7 +81,8 @@ impl SubdirBench { let provisioned = josh_test_support::provision_repo::provision_repo( "deephistory_subdir_sparse", - &git2::Oid::from_str(EXPECTED_HEAD).expect("EXPECTED_HEAD must be a valid oid"), + &gix_hash::ObjectId::from_str(EXPECTED_HEAD) + .expect("EXPECTED_HEAD must be a valid oid"), |repo| { let mut heads = vec![]; for &n_commits in HISTORY_SIZES { @@ -99,7 +101,10 @@ impl SubdirBench { let repo = &provisioned.repo; for &n_commits in HISTORY_SIZES { let head = repo.refname_to_id(&format!("refs/heads/case_{n_commits}"))?; - cases.push(SizeCase { n_commits, head }); + cases.push(SizeCase { + n_commits, + head: gix_oid(head), + }); } } @@ -125,9 +130,9 @@ impl SubdirBench { // sees what is on disk. transaction.flush_mem_odb()?; let repo = transaction.git2_repo(); - let filtered_tree = repo.find_commit(filtered)?.tree()?.id(); + let filtered_tree = repo.find_commit(git2_oid(filtered))?.tree()?.id(); let raw_subdir_tree = repo - .find_commit(case.head)? + .find_commit(git2_oid(case.head))? .tree()? .get_path(Path::new(SUBDIR))? .id(); @@ -159,9 +164,9 @@ impl SubdirBench { } /// Number of commits reachable from `head`. -fn count_history(repo: &git2::Repository, head: git2::Oid) -> anyhow::Result { +fn count_history(repo: &git2::Repository, head: gix_hash::ObjectId) -> anyhow::Result { let mut walk = repo.revwalk()?; - walk.push(head)?; + walk.push(git2_oid(head))?; Ok(walk.count()) } @@ -180,7 +185,7 @@ fn random_string(rng: &mut StdRng, len: usize) -> String { /// `CHURN_PER_COMMIT` files outside it, and only with probability `SUBDIR_CHANGE_PROB` also touches a /// `dir_00` file. The tip is tagged `refs/heads/case_` so the head survives the cache /// round-trip. -fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result { +fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result { use rand::RngExt; // Deterministic root tree: file `i` lives at `dir_{i % N_DIRS}/file_{i}`; split the paths into the @@ -261,27 +266,30 @@ fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result anyhow::Result { +fn build_index( + repo: &git2::Repository, + heads: &[gix_hash::ObjectId], +) -> anyhow::Result { let sig = josh_commit_signature()?; let empty_tree = repo.find_tree(repo.treebuilder(None)?.write()?)?; let parents = heads .iter() - .map(|oid| repo.find_commit(*oid)) + .map(|oid| repo.find_commit(git2_oid(*oid))) .collect::, _>>()?; let parent_refs = parents.iter().collect::>(); - Ok(repo.commit( + Ok(gix_oid(repo.commit( Some("refs/heads/bench-index"), &sig, &sig, "bench index", &empty_tree, &parent_refs, - )?) + )?)) } fn deephistory_subdir_sparse(c: &mut Criterion) { diff --git a/josh-core/benches/refs_filter_update.rs b/josh-core/benches/refs_filter_update.rs index 26148e66e..8c39898ac 100644 --- a/josh-core/benches/refs_filter_update.rs +++ b/josh-core/benches/refs_filter_update.rs @@ -4,6 +4,7 @@ use josh_core::git::josh_commit_signature; use josh_test_support::bench::{EntryKind, build_index, git2_oid, gix_oid, random_string}; use rand::prelude::*; use std::path::{Path, PathBuf}; +use std::str::FromStr; // This bench measures the *reference* surface: enumerating refs by prefix, resolving each to an oid, // looking up the (cache-hot) filtered result per ref, and force-writing the filtered refs back. The @@ -60,7 +61,7 @@ const JOSH_BENCH_COMMIT_TIME: &str = "1700000000"; /// One ref-count case and the head of its generated history. struct SizeCase { n_refs: usize, - head: git2::Oid, + head: gix_hash::ObjectId, } struct RefsBench { @@ -91,7 +92,8 @@ impl RefsBench { // checked against `EXPECTED_HEAD`. let provisioned = josh_test_support::provision_repo::provision_repo( CACHE_NAME, - &git2::Oid::from_str(EXPECTED_HEAD).expect("EXPECTED_HEAD must be a valid oid"), + &gix_hash::ObjectId::from_str(EXPECTED_HEAD) + .expect("EXPECTED_HEAD must be a valid oid"), |repo| { let mut heads = vec![]; for &n_refs in REF_COUNTS { @@ -110,7 +112,10 @@ impl RefsBench { let repo = &provisioned.repo; for &n_refs in REF_COUNTS { let head = repo.refname_to_id(&format!("refs/heads/case_{n_refs}_tip"))?; - cases.push(SizeCase { n_refs, head }); + cases.push(SizeCase { + n_refs, + head: gix_oid(head), + }); } } @@ -134,9 +139,9 @@ impl RefsBench { // sees what is on disk. transaction.flush_mem_odb()?; let repo = transaction.git2_repo(); - let filtered_tree = repo.find_commit(filtered)?.tree()?.id(); + let filtered_tree = repo.find_commit(git2_oid(filtered))?.tree()?.id(); let raw_subdir_tree = repo - .find_commit(case.head)? + .find_commit(git2_oid(case.head))? .tree()? .get_path(Path::new(SUBDIR))? .id(); @@ -169,7 +174,7 @@ impl RefsBench { /// directories, then generate `n_refs` churn commits, pointing `refs/heads/case_/change_` at /// each. The tip additionally gets `refs/heads/case__tip` so the head is recoverable after /// the repo round-trips through the cache. -fn build_case(repo: &git2::Repository, n_refs: usize) -> anyhow::Result { +fn build_case(repo: &git2::Repository, n_refs: usize) -> anyhow::Result { use rand::RngExt; let gix_repo = gix::open(repo.path())?; @@ -243,7 +248,7 @@ fn build_case(repo: &git2::Repository, n_refs: usize) -> anyhow::Result josh_filter::Filter { fn build_initial_state( repo: &git2::Repository, -) -> anyhow::Result<(git2::Oid, Vec)> { +) -> anyhow::Result<(gix_hash::ObjectId, Vec)> { const PATH_COMPONENT_LENGTH: usize = 15; // Create multiple nested subfolders in the benchmark repo; aiming for a uniform @@ -178,14 +180,14 @@ fn build_initial_state( &[], )?; - Ok((head, all_paths)) + Ok((gix_oid(head), all_paths)) } fn build_history( repo: &git2::Repository, paths: &[std::path::PathBuf], - mut head: git2::Oid, -) -> anyhow::Result { + mut head: gix_hash::ObjectId, +) -> anyhow::Result { use rand::RngExt; // In every commit, we update 10% files in the repo @@ -212,7 +214,7 @@ fn build_history( let mut pinned = std::collections::BTreeSet::::new(); for i_commit in 0..N_COMMITS { - let parent = repo.find_commit(head)?; + let parent = repo.find_commit(git2_oid(head))?; let tree = parent.tree()?; let mut builder = gix_repo.edit_tree(gix_oid(tree.id()))?; @@ -265,14 +267,14 @@ fn build_history( let new_tree = repo.find_tree(new_tree)?; let sig = josh_commit_signature()?; - head = repo.commit( + head = gix_oid(repo.commit( Some("refs/heads/main"), &sig, &sig, &format!("commit {i_commit}"), &new_tree, &[&parent], - )?; + )?); } Ok(head) diff --git a/josh-core/benches/ultrawide_pin_hook.rs b/josh-core/benches/ultrawide_pin_hook.rs index 6502d13fc..f03719857 100644 --- a/josh-core/benches/ultrawide_pin_hook.rs +++ b/josh-core/benches/ultrawide_pin_hook.rs @@ -4,6 +4,7 @@ use josh_test_support::bench::{EntryKind, git2_oid, gix_oid}; use rand::prelude::*; use std::collections::HashMap; use std::path::PathBuf; +use std::str::FromStr; use std::sync::Arc; // Tree sizes (number of files) benchmarked. Kept small in debug builds so `--test` runs stay fast. @@ -73,14 +74,14 @@ const JOSH_BENCH_COMMIT_TIME: &str = "1700000000"; // pin through a stored `workspace.josh`, which pays a filter parse+legalize per commit; the hook // serves a pre-built per-commit filter by oid instead, isolating the pin evaluation itself. struct BenchPinHook { - per_path: HashMap, - one_tree: HashMap, + per_path: HashMap, + one_tree: HashMap, } impl josh_core::cache::FilterHook for BenchPinHook { fn filter_for_commit( &self, - commit_oid: git2::Oid, + commit_oid: gix_hash::ObjectId, arg: &str, ) -> anyhow::Result { let map = match arg { @@ -98,7 +99,7 @@ impl josh_core::cache::FilterHook for BenchPinHook { /// shared hook. struct SizeCase { size: usize, - head: git2::Oid, + head: gix_hash::ObjectId, } struct PinBench { @@ -133,7 +134,8 @@ impl PinBench { // `EXPECTED_HEAD`. let provisioned = josh_test_support::provision_repo::provision_repo( "ultrawide_pin_hook", - &git2::Oid::from_str(EXPECTED_HEAD).expect("EXPECTED_HEAD must be a valid oid"), + &gix_hash::ObjectId::from_str(EXPECTED_HEAD) + .expect("EXPECTED_HEAD must be a valid oid"), |repo| { let mut heads = vec![]; for &size in SIZES { @@ -156,7 +158,7 @@ impl PinBench { { let repo = &provisioned.repo; for &size in SIZES { - let head = repo.refname_to_id(&format!("refs/heads/case_{size}"))?; + let head = gix_oid(repo.refname_to_id(&format!("refs/heads/case_{size}"))?); record_case(repo, head, &mut per_path, &mut one_tree)?; cases.push(SizeCase { size, head }); } @@ -290,7 +292,7 @@ fn pin_filter_tree( gix_oid(placeholder), )?; } - let tree = git2_oid(builder.write()?.detach()); + let tree = builder.write()?.detach(); let param = josh_filter::Filter::new().insert_oid(".", tree)?; Ok(josh_filter::Filter::new().pin(param)) } @@ -299,7 +301,7 @@ fn pin_filter_tree( /// per commit. The tip is tagged with `refs/heads/case_` so the head is recoverable after the /// repo round-trips through the cache; the pinned set and per-commit pin filters are not recorded here /// -- they are reconstructed from tree diffs in `record_case`. -fn build_case(repo: &git2::Repository, size: usize) -> anyhow::Result { +fn build_case(repo: &git2::Repository, size: usize) -> anyhow::Result { use rand::RngExt; // Distribute files uniformly across nested subfolders. @@ -383,18 +385,21 @@ fn build_case(repo: &git2::Repository, size: usize) -> anyhow::Result "bench case tip", )?; - Ok(head) + Ok(gix_oid(head)) } /// Aggregate every case tip under one index commit. Its oid changes whenever any case head changes, /// making it a faithful content-addressed cache stamp for the entire repo, and it keeps all cases /// reachable so provision_repo's `git prune` retains the full history. It is never filtered. -fn build_index(repo: &git2::Repository, heads: &[git2::Oid]) -> anyhow::Result { +fn build_index( + repo: &git2::Repository, + heads: &[gix_hash::ObjectId], +) -> anyhow::Result { let sig = josh_commit_signature()?; let empty_tree = repo.find_tree(repo.treebuilder(None)?.write()?)?; let parents = heads .iter() - .map(|oid| repo.find_commit(*oid)) + .map(|oid| repo.find_commit(git2_oid(*oid))) .collect::, _>>()?; let parent_refs = parents.iter().collect::>(); let index = repo.commit( @@ -405,7 +410,7 @@ fn build_index(repo: &git2::Repository, heads: &[git2::Oid]) -> anyhow::Result anyhow::Result, - one_tree: &mut HashMap, + head: gix_hash::ObjectId, + per_path: &mut HashMap, + one_tree: &mut HashMap, ) -> anyhow::Result<()> { // Collect the linear history oldest-first so the pinned set can be folded forward across commits. let mut chain = vec![]; let mut oid = head; loop { - let commit = repo.find_commit(oid)?; + let commit = repo.find_commit(git2_oid(oid))?; chain.push(oid); if commit.parent_count() == 0 { break; } - oid = commit.parent_id(0)?; + oid = gix_oid(commit.parent_id(0)?); } chain.reverse(); let mut pinned = std::collections::BTreeSet::::new(); for (commit_index, &oid) in chain.iter().enumerate() { - let commit = repo.find_commit(oid)?; + let commit = repo.find_commit(git2_oid(oid))?; // Re-roll the hold status of every churned path; unchurned paths keep the status they carried // over. The root commit has no parent, so its churned set is empty and the pinned set stays diff --git a/josh-core/benches/unapply.rs b/josh-core/benches/unapply.rs index bca1eac11..8b2ebd204 100644 --- a/josh-core/benches/unapply.rs +++ b/josh-core/benches/unapply.rs @@ -5,6 +5,7 @@ use josh_core::history::{OrphansMode, unapply_filter}; use josh_test_support::bench::{EntryKind, git2_oid, gix_oid}; use rand::prelude::*; use std::path::PathBuf; +use std::str::FromStr; use std::sync::atomic::{AtomicUsize, Ordering}; // Benches the unapply (push) path of josh: a client pushes commits built on the FILTERED @@ -47,11 +48,11 @@ const JOSH_BENCH_COMMIT_TIME: &str = "1700000000"; struct SizeCase { n_commits: usize, - head: git2::Oid, - filtered_head: git2::Oid, + head: gix_hash::ObjectId, + filtered_head: gix_hash::ObjectId, /// A filtered commit roughly in the middle of the filtered history (first-parent /// chase), the tip of the simulated new-branch push. - filtered_mid: git2::Oid, + filtered_mid: gix_hash::ObjectId, } struct UnapplyBench { @@ -72,7 +73,8 @@ impl UnapplyBench { let provisioned = josh_test_support::provision_repo::provision_repo( "unapply", - &git2::Oid::from_str(EXPECTED_HEAD).expect("EXPECTED_HEAD must be a valid oid"), + &gix_hash::ObjectId::from_str(EXPECTED_HEAD) + .expect("EXPECTED_HEAD must be a valid oid"), |repo| { let mut heads = vec![]; for &n_commits in HISTORY_SIZES { @@ -101,7 +103,7 @@ impl UnapplyBench { let transaction = context.open()?; let repo = transaction.git2_repo(); for &n_commits in HISTORY_SIZES { - let head = repo.refname_to_id(&format!("refs/heads/case_{n_commits}"))?; + let head = gix_oid(repo.refname_to_id(&format!("refs/heads/case_{n_commits}"))?); let filtered_head = josh_core::filter_commit(&transaction, filter, head)?; // The first-parent chase below reads the filtered commits through the // repository handle, which only sees what is on disk. @@ -110,9 +112,9 @@ impl UnapplyBench { // First-parent chase to the middle of the filtered history. let mut filtered_mid = filtered_head; for _ in 0..n_commits / 2 { - let commit = repo.find_commit(filtered_mid)?; + let commit = repo.find_commit(git2_oid(filtered_mid))?; match commit.parent_id(0) { - Ok(p) => filtered_mid = p, + Ok(p) => filtered_mid = gix_oid(p), Err(_) => break, } } @@ -147,7 +149,7 @@ fn random_string(rng: &mut StdRng, len: usize) -> String { /// deephistory-style case builder: fixed tree, `n_commits` of ~10% churn, tip tagged as /// `refs/heads/case_`. -fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result { +fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result { use rand::RngExt; let gix_repo = gix::open(repo.path())?; @@ -210,15 +212,18 @@ fn build_case(repo: &git2::Repository, n_commits: usize) -> anyhow::Result anyhow::Result { +fn build_index( + repo: &git2::Repository, + heads: &[gix_hash::ObjectId], +) -> anyhow::Result { let sig = josh_commit_signature()?; let empty_tree = repo.find_tree(repo.treebuilder(None)?.write()?)?; let parents = heads .iter() - .map(|oid| repo.find_commit(*oid)) + .map(|oid| repo.find_commit(git2_oid(*oid))) .collect::, _>>()?; let parent_refs = parents.iter().collect::>(); let index = repo.commit( @@ -229,7 +234,7 @@ fn build_index(repo: &git2::Repository, heads: &[git2::Oid]) -> anyhow::Result anyhow::Result anyhow::Result { +) -> anyhow::Result { let sig = josh_commit_signature()?; let mut head = base; let gix_repo = gix::open(repo.path())?; for i in 0..PUSH_LEN { - let parent = repo.find_commit(head)?; + let parent = repo.find_commit(git2_oid(head))?; let tree = parent.tree()?; let mut builder = gix_repo.edit_tree(gix_oid(tree.id()))?; let blob = repo.blob(format!("push {salt} {i}").as_bytes())?; builder.upsert("file_0000", EntryKind::Blob, gix_oid(blob))?; let new_tree = repo.find_tree(git2_oid(builder.write()?.detach()))?; - head = repo.commit( + head = gix_oid(repo.commit( None, &sig, &sig, &format!("push {salt} {i}"), &new_tree, &[&parent], - )?; + )?); } Ok(head) } @@ -350,7 +355,7 @@ fn unapply_new_branch(c: &mut Criterion) { &transaction, bench.filter, case.head, - git2::Oid::ZERO_SHA1, + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), case.filtered_mid, OrphansMode::Fail, None, @@ -380,7 +385,7 @@ fn unapply_new_branch(c: &mut Criterion) { &transaction, bench.filter, case.head, - git2::Oid::ZERO_SHA1, + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), case.filtered_mid, OrphansMode::Fail, None, diff --git a/josh-core/benches/widetree_glob.rs b/josh-core/benches/widetree_glob.rs index f0f77c2e2..7abaecff7 100644 --- a/josh-core/benches/widetree_glob.rs +++ b/josh-core/benches/widetree_glob.rs @@ -6,6 +6,7 @@ use josh_test_support::bench::{ }; use rand::prelude::*; use std::path::{Path, PathBuf}; +use std::str::FromStr; // The scaling parameter of this benchmark is tree *width*, not history length. History stays a // fixed, short N_COMMITS while the number of files grows per case; each `filter_commit` apply of a @@ -74,7 +75,7 @@ const JOSH_BENCH_COMMIT_TIME: &str = "1700000000"; /// One tree width and the head of its generated history. struct SizeCase { n_files: usize, - head: git2::Oid, + head: gix_hash::ObjectId, } struct GlobBench { @@ -103,7 +104,8 @@ impl GlobBench { // `EXPECTED_HEAD`. let provisioned = josh_test_support::provision_repo::provision_repo( CACHE_NAME, - &git2::Oid::from_str(EXPECTED_HEAD).expect("EXPECTED_HEAD must be a valid oid"), + &gix_hash::ObjectId::from_str(EXPECTED_HEAD) + .expect("EXPECTED_HEAD must be a valid oid"), |repo| { let mut heads = vec![]; for &n_files in TREE_SIZES { @@ -122,7 +124,10 @@ impl GlobBench { let repo = &provisioned.repo; for &n_files in TREE_SIZES { let head = repo.refname_to_id(&format!("refs/heads/case_{n_files}"))?; - cases.push(SizeCase { n_files, head }); + cases.push(SizeCase { + n_files, + head: gix_oid(head), + }); } } @@ -152,11 +157,11 @@ impl GlobBench { // The gate compares against an independent git2 reference model, which only // sees what is on disk. transaction.flush_mem_odb()?; - let got = repo.find_commit(filtered)?.tree_id(); + let got = repo.find_commit(git2_oid(filtered))?.tree_id(); let (want, kept) = expected_tree(repo, case.head, &|p| p.ends_with(".rs"))?; anyhow::ensure!(kept > 0, "recursive gate kept no blobs -- would be a no-op"); anyhow::ensure!( - got == want, + gix_oid(got) == want, "`::{PATTERN_RECURSIVE}` produced {got}, expected {want} (n_files {})", case.n_files ); @@ -168,13 +173,13 @@ impl GlobBench { let filter = Filter::new().pattern(PATTERN_PREFIX).expect("valid glob"); let filtered = josh_core::filter_commit(&transaction, filter, case.head)?; transaction.flush_mem_odb()?; - let got_tree = repo.find_commit(filtered)?.tree()?; + let got_tree = repo.find_commit(git2_oid(filtered))?.tree()?; anyhow::ensure!( got_tree.len() == 1, "`::{PATTERN_PREFIX}` result must have exactly one top-level entry" ); let raw_subtree = repo - .find_commit(case.head)? + .find_commit(git2_oid(case.head))? .tree()? .get_path(Path::new(PREFIX_DIR))? .id(); @@ -187,14 +192,14 @@ impl GlobBench { let filter = Filter::new().pattern(PATTERN_SPARSE).expect("valid glob"); let filtered = josh_core::filter_commit(&transaction, filter, case.head)?; transaction.flush_mem_odb()?; - let got = repo.find_commit(filtered)?.tree_id(); + let got = repo.find_commit(git2_oid(filtered))?.tree_id(); let (want, kept) = expected_tree(repo, case.head, &|p| p.ends_with(".toml"))?; anyhow::ensure!( kept == N_SPARSE, "sparse gate kept {kept} blobs, expected exactly {N_SPARSE}" ); anyhow::ensure!( - got == want, + gix_oid(got) == want, "`::{PATTERN_SPARSE}` produced {got}, expected {want} (n_files {})", case.n_files ); @@ -227,7 +232,7 @@ fn file_path(i: usize) -> PathBuf { /// CHURN_PER_COMMIT files at deterministic indices. The tip is tagged with /// `refs/heads/case_` so the head is recoverable after the repo round-trips through the /// cache. -fn build_case(repo: &git2::Repository, n_files: usize) -> anyhow::Result { +fn build_case(repo: &git2::Repository, n_files: usize) -> anyhow::Result { let gix_repo = gix::open(repo.path())?; let baseline = repo.treebuilder(None)?.write()?; let mut builder = gix_repo.edit_tree(gix_oid(baseline))?; @@ -298,7 +303,7 @@ fn build_case(repo: &git2::Repository, n_files: usize) -> anyhow::Result anyhow::Result>; + ) -> anyhow::Result>; fn write( &self, filter: crate::filter::Filter, - from: git2::Oid, - to: git2::Oid, + from: gix_hash::ObjectId, + to: gix_hash::ObjectId, hint: HistoryGraphHint, tree_keyed: bool, ) -> anyhow::Result<()>; diff --git a/josh-core/src/cache/distributed.rs b/josh-core/src/cache/distributed.rs index e5bbd9441..ccd1842b6 100644 --- a/josh-core/src/cache/distributed.rs +++ b/josh-core/src/cache/distributed.rs @@ -11,7 +11,8 @@ use std::collections::HashMap; const FLUSH_AFTER: usize = 1000; pub struct DistributedCacheBackend { - new_entries: std::sync::Mutex>>, + new_entries: + std::sync::Mutex>>, repo: std::sync::Mutex, // Whether this backend accepts writes. The default ([`Self::new`]) is read-only: regular // sessions consume the fetched cache but should not each grow the shard chains with a @@ -30,11 +31,11 @@ pub struct DistributedCacheBackend { // in `mem_odb` only, so the refs are published exclusively by a forced flush, after a drain // has made every buffered object durable: a ref on disk must never point to objects that // only exist in memory. - pending_refs: std::sync::Mutex>, + pending_refs: std::sync::Mutex>, // Filter -> persisted tree id (`as_tree`), used to name cache refs. `as_tree` resolves // insert OIDs, so ref names always reference persisted, reachable filter trees even when // the filter passed in still contains unresolved ones. - tree_ids: std::sync::Mutex>, + tree_ids: std::sync::Mutex>, } impl Drop for DistributedCacheBackend { @@ -60,7 +61,7 @@ impl DistributedCacheBackend { fn open(repo_path: impl AsRef, writable: bool) -> anyhow::Result { let repo = git2::Repository::open(repo_path.as_ref())?; - let objects_dir = josh_memodb::objects_dir(&repo); + let objects_dir = repo.commondir().join("objects"); let mem_odb = josh_memodb::MemOdb::new(None, objects_dir.clone()); let odb = josh_memodb::Odb::at(mem_odb.clone(), &objects_dir)?; Ok(Self { @@ -74,7 +75,11 @@ impl DistributedCacheBackend { }) } - fn tree_id(&self, odb: &josh_memodb::Odb, filter: Filter) -> anyhow::Result { + fn tree_id( + &self, + odb: &josh_memodb::Odb, + filter: Filter, + ) -> anyhow::Result { if let Some(oid) = self.tree_ids.lock().unwrap().get(&filter) { return Ok(*oid); } @@ -99,14 +104,11 @@ impl DistributedCacheBackend { } let rp = ref_path(self.tree_id(odb, *filter)?, *shard); - // Base the update on the newest unpublished commit for this ref when one exists: - // basing on the published tip would drop the entries of earlier unpublished - // batches. + // Include earlier unpublished batches. let base = if let Some(oid) = pending.get(&rp) { Some(*oid) } else if let Ok(r) = repo.revparse_single(&rp) { - // PORT: resolves a ref -- stays on git2 until flag day. - Some(r.peel_to_commit()?.id()) + Some(objects::gix_oid(r.peel_to_commit()?.id())) } else { None }; @@ -115,7 +117,7 @@ impl DistributedCacheBackend { let root = match base { Some(commit) => { let tree = objects::CommitData::read(odb, commit)?.tree_id()?; - gix_object::FindExt::find_tree(odb, &objects::gix_oid(tree), &mut buf)?.into() + gix_object::FindExt::find_tree(odb, &tree, &mut buf)?.into() } None => gix_object::Tree::default(), }; @@ -128,7 +130,7 @@ impl DistributedCacheBackend { // in tree entries -- so it is encoded as a blob entry pointing at the empty blob; // the entry mode disambiguates on read. for (from, to) in &mut *m { - let (kind, target) = if *to == git2::Oid::ZERO_SHA1 { + let (kind, target) = if *to == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { ( gix_object::tree::EntryKind::Blob, objects::write_blob(odb, &[])?, @@ -136,7 +138,7 @@ impl DistributedCacheBackend { } else { (gix_object::tree::EntryKind::Commit, *to) }; - editor.upsert(fanout(*from), kind, objects::gix_oid(target))?; + editor.upsert(fanout(*from), kind, target)?; } let updated = editor.write(|tree| { @@ -147,7 +149,7 @@ impl DistributedCacheBackend { let signature = crate::git::josh_commit_signature()?; let commit = objects::write_commit( odb, - objects::git2_oid(&updated), + updated, base.as_slice(), &signature, &signature, @@ -178,7 +180,7 @@ impl DistributedCacheBackend { self.mem_odb.flush()?; for (rp, commit) in pending.drain() { - repo.reference(&rp, commit, true, "cache")?; + repo.reference(&rp, objects::git2_oid(&commit), true, "cache")?; } Ok(()) @@ -197,7 +199,7 @@ impl DistributedCacheBackend { // To additionally limit the size of the trees the cache is also sharded by sequence // number in groups of 10000. Note that this does not limit the number of entries per bucket // as branches mean many commits share the same sequence number. -fn ref_path(filter_tree_id: git2::Oid, shard: u64) -> String { +fn ref_path(filter_tree_id: gix_hash::ObjectId, shard: u64) -> String { format!( "refs/josh/cache/{}/{}/{}", CACHE_VERSION, shard, filter_tree_id, @@ -209,7 +211,7 @@ fn ref_path(filter_tree_id: git2::Oid, shard: u64) -> String { // rewrites subtrees that grow with the accumulated shard. A single 2-hex level goes quadratic on // dense shards for exactly that reason, while a third level only adds one more tree write per // entry without making any subtree meaningfully smaller. -fn fanout(commit: git2::Oid) -> [gix_object::bstr::BString; 3] { +fn fanout(commit: gix_hash::ObjectId) -> [gix_object::bstr::BString; 3] { let commit = commit.to_string(); [commit[..2].into(), commit[2..5].into(), commit[5..].into()] } @@ -218,10 +220,10 @@ impl CacheBackend for DistributedCacheBackend { fn read( &self, filter: Filter, - from: git2::Oid, + from: gix_hash::ObjectId, hint: HistoryGraphHint, tree_keyed: bool, - ) -> anyhow::Result> { + ) -> anyhow::Result> { if filter == filter::sequence_number() || filter == filter::reachable_roots() { return Ok(None); } @@ -248,21 +250,19 @@ impl CacheBackend for DistributedCacheBackend { let odb = self.odb.lock().unwrap(); let odb = &*odb; let rp = ref_path(self.tree_id(odb, filter)?, shard); - // Flushed-but-unpublished entries live in a pending commit (see `flush`), not behind - // the ref yet; prefer it so in-process reads keep seeing everything ever flushed. + // Prefer unpublished entries from this process. let pending = self.pending_refs.lock().unwrap(); let tree = if let Some(oid) = pending.get(&rp) { objects::CommitData::read(odb, *oid)?.tree_id()? } else if let Ok(r) = repo.revparse_single(&rp) { - // PORT: resolves a ref -- stays on git2 until flag day. - r.peel_to_tree()?.id() + objects::gix_oid(r.peel_to_tree()?.id()) } else { return Ok(None); }; std::mem::drop(pending); let mut buf = Vec::new(); - let root = gix_object::FindExt::find_tree_iter(odb, &objects::gix_oid(tree), &mut buf)?; + let root = gix_object::FindExt::find_tree_iter(odb, &tree, &mut buf)?; let mut entry_buf = Vec::new(); let entry = root .lookup_entry(odb, &mut entry_buf, fanout(from)) @@ -279,16 +279,16 @@ impl CacheBackend for DistributedCacheBackend { // Gitlink entries carry the target oid directly; any other mode is the empty-blob // encoding of `Oid::ZERO_SHA1` (see `flush`). if e.mode.kind() == gix_object::tree::EntryKind::Commit { - return Ok(Some(objects::git2_oid(&e.oid))); + return Ok(Some(e.oid)); } - Ok(Some(git2::Oid::ZERO_SHA1)) + Ok(Some(gix_hash::ObjectId::null(gix_hash::Kind::Sha1))) } fn write( &self, filter: Filter, - from: git2::Oid, - to: git2::Oid, + from: gix_hash::ObjectId, + to: gix_hash::ObjectId, hint: HistoryGraphHint, // Writes stay eligibility-gated for tree-keyed records too: subtrees recur // across commits, so a stable subtree is still caught at some sampled commit. diff --git a/josh-core/src/cache/history_graph.rs b/josh-core/src/cache/history_graph.rs index 066c5e4e0..f7753ee40 100644 --- a/josh-core/src/cache/history_graph.rs +++ b/josh-core/src/cache/history_graph.rs @@ -29,7 +29,7 @@ use super::transaction::Transaction; #[derive(Debug, Clone)] pub struct HistoryGraphInfo { pub sequence_number: u64, - pub reachable_roots: Vec, + pub reachable_roots: Vec, } /// Returns just the sequence number for `input`. @@ -38,7 +38,10 @@ pub struct HistoryGraphInfo { /// sequence number is available directly from the cached hint, so callers that /// only compare sequence numbers avoid a per-commit `find_blob` + parse that /// would otherwise be discarded. -pub fn compute_sequence_number(transaction: &Transaction, input: git2::Oid) -> anyhow::Result { +pub fn compute_sequence_number( + transaction: &Transaction, + input: gix_hash::ObjectId, +) -> anyhow::Result { Ok(ensure_hint_cached(transaction, input)?.0.sequence_number) } @@ -47,7 +50,7 @@ pub fn compute_sequence_number(transaction: &Transaction, input: git2::Oid) -> a /// eligibility decisions without any commit read. pub fn compute_history_hint( transaction: &Transaction, - input: git2::Oid, + input: gix_hash::ObjectId, ) -> anyhow::Result { Ok(ensure_hint_cached(transaction, input)?.0) } @@ -60,7 +63,7 @@ pub fn compute_history_hint( /// commit reuses its parent's blob OID, avoiding read/write entirely. pub fn collect_history_graph_info( transaction: &Transaction, - input: git2::Oid, + input: gix_hash::ObjectId, ) -> anyhow::Result { let (hint, blob) = ensure_hint_cached(transaction, input)?; @@ -78,16 +81,20 @@ pub fn collect_history_graph_info( /// `Ok(false)` (matching `merge_base_many`'s error behavior on invalid input). pub fn parents_share_root( transaction: &Transaction, - parent_ids: &[git2::Oid], + parent_ids: &[gix_hash::ObjectId], ) -> anyhow::Result { - if parent_ids.is_empty() || parent_ids.iter().any(|x| *x == git2::Oid::ZERO_SHA1) { + if parent_ids.is_empty() + || parent_ids + .iter() + .any(|x| *x == gix_hash::ObjectId::null(gix_hash::Kind::Sha1)) + { return Ok(false); } // Ensure each parent's graph info is cached, then collect the cached blob // OIDs. If all parents reference the same blob, their root sets are // identical — they trivially share every root without reading any blob. - let parent_blobs: Vec = parent_ids + let parent_blobs: Vec = parent_ids .iter() .map(|p| Ok(ensure_hint_cached(transaction, *p)?.1)) .collect::>>()?; @@ -98,7 +105,7 @@ pub fn parents_share_root( } // Parents disagree on the roots blob: read each blob and intersect. - let mut common: std::collections::BTreeSet = + let mut common: std::collections::BTreeSet = read_roots_blob(transaction.odb(), first_blob)? .into_iter() .collect(); @@ -122,8 +129,8 @@ pub fn parents_share_root( /// and writes. fn ensure_hint_cached( transaction: &Transaction, - input: git2::Oid, -) -> anyhow::Result<(HistoryGraphHint, git2::Oid)> { + input: gix_hash::ObjectId, +) -> anyhow::Result<(HistoryGraphHint, gix_hash::ObjectId)> { if let Some(hint) = try_read_cached_hint(transaction, input)? { return Ok(hint); } @@ -136,7 +143,7 @@ fn ensure_hint_cached( let parent_ids = crate::git::read_parent_ids(odb, input)?; // Fast path: every parent already has both pieces cached. - let parents_hint: Option> = parent_ids + let parents_hint: Option> = parent_ids .iter() .map(|p| { Ok(try_read_cached_hint(transaction, *p)? @@ -170,7 +177,7 @@ fn ensure_hint_cached( })?; for &oid in sorted.iter().rev() { - let parents_hint: Vec<(u64, git2::Oid)> = crate::git::read_parent_ids(odb, oid)? + let parents_hint: Vec<(u64, gix_hash::ObjectId)> = crate::git::read_parent_ids(odb, oid)? .into_iter() .map(|p| { try_read_cached_hint(transaction, p)? @@ -192,9 +199,9 @@ fn ensure_hint_cached( /// OID (or, for the root case, writes a single-element blob). fn derive_from_parents( odb: &josh_memodb::Odb, - self_oid: git2::Oid, - parents_hint: &[(u64, git2::Oid)], -) -> anyhow::Result<(HistoryGraphHint, git2::Oid)> { + self_oid: gix_hash::ObjectId, + parents_hint: &[(u64, gix_hash::ObjectId)], +) -> anyhow::Result<(HistoryGraphHint, gix_hash::ObjectId)> { if parents_hint.is_empty() { // Parentless: this commit *is* its own only reachable root. return Ok(( @@ -230,7 +237,7 @@ fn derive_from_parents( let roots_blob = if parents_hint.iter().all(|(_, b)| *b == first_blob) { first_blob } else { - let mut set: std::collections::BTreeSet = Default::default(); + let mut set: std::collections::BTreeSet = Default::default(); for (_, blob_oid) in parents_hint { set.extend(read_roots_blob(odb, *blob_oid)?); } @@ -251,8 +258,8 @@ fn derive_from_parents( fn try_read_cached_hint( transaction: &Transaction, - input: git2::Oid, -) -> anyhow::Result> { + input: gix_hash::ObjectId, +) -> anyhow::Result> { let Some(seq) = transaction.get(crate::filter::sequence_number(), input)? else { return Ok(None); }; @@ -264,8 +271,8 @@ fn try_read_cached_hint( fn store_hint( transaction: &Transaction, - input: git2::Oid, - hint: (HistoryGraphHint, git2::Oid), + input: gix_hash::ObjectId, + hint: (HistoryGraphHint, gix_hash::ObjectId), ) -> anyhow::Result<()> { let (hint, roots_blob) = hint; transaction.insert( @@ -278,7 +285,10 @@ fn store_hint( Ok(()) } -fn write_roots_blob(odb: &josh_memodb::Odb, roots: &[git2::Oid]) -> anyhow::Result { +fn write_roots_blob( + odb: &josh_memodb::Odb, + roots: &[gix_hash::ObjectId], +) -> anyhow::Result { let mut bytes = Vec::with_capacity(roots.len() * 20); for r in roots { bytes.extend_from_slice(r.as_bytes()); @@ -286,7 +296,10 @@ fn write_roots_blob(odb: &josh_memodb::Odb, roots: &[git2::Oid]) -> anyhow::Resu Ok(odb.write(gix_object::Kind::Blob, &bytes)) } -fn read_roots_blob(odb: &josh_memodb::Odb, oid: git2::Oid) -> anyhow::Result> { +fn read_roots_blob( + odb: &josh_memodb::Odb, + oid: gix_hash::ObjectId, +) -> anyhow::Result> { let (kind, content) = odb.read(oid)?; if kind != gix_object::Kind::Blob { return Err(anyhow!("reachable_roots object {} is not a blob", oid)); @@ -301,7 +314,7 @@ fn read_roots_blob(odb: &josh_memodb::Odb, oid: git2::Oid) -> anyhow::Result anyhow::Result git2::Oid { +pub(crate) fn oid_from_hint(hint: HistoryGraphHint) -> gix_hash::ObjectId { let mut bytes = [0u8; 20]; bytes[10] = ((hint.jump_is_second as u8) << 7) | hint.jump_delta; bytes[11] = hint.parent_count; // place the 8 integer bytes at the end (big-endian) bytes[20 - 8..].copy_from_slice(&hint.sequence_number.to_be_bytes()); // Safe: length is exactly 20 - git2::Oid::from_bytes(&bytes).expect("20-byte OID construction cannot fail") + gix_hash::ObjectId::from_bytes_or_panic(&bytes) } /// Decode a hint from an OID encoded by `oid_from_hint`. -pub(crate) fn hint_from_oid(oid: git2::Oid) -> HistoryGraphHint { +pub(crate) fn hint_from_oid(oid: gix_hash::ObjectId) -> HistoryGraphHint { let b = oid.as_bytes(); let mut n = [0u8; 8]; n.copy_from_slice(&b[20 - 8..]); // take the last 8 bytes diff --git a/josh-core/src/cache/sled.rs b/josh-core/src/cache/sled.rs index 9d29d7042..9c872a167 100644 --- a/josh-core/src/cache/sled.rs +++ b/josh-core/src/cache/sled.rs @@ -17,7 +17,7 @@ struct State { /// Cache directory, set by [`SledCacheBackend::new`]. `None` until the first backend is built. path: Option, db: Option, - trees: std::collections::HashMap, + trees: std::collections::HashMap, active: usize, } @@ -102,14 +102,14 @@ impl CacheBackend for SledCacheBackend { fn read( &self, filter: Filter, - from: git2::Oid, + from: gix_hash::ObjectId, _hint: HistoryGraphHint, // Sled stores records by filter alone, so the key kind needs no special handling. _tree_keyed: bool, - ) -> anyhow::Result> { + ) -> anyhow::Result> { let tree = STATE.lock().unwrap().tree(filter)?; if let Some(oid) = tree.get(from.as_bytes())? { - Ok(Some(git2::Oid::from_bytes(&oid)?)) + Ok(Some(gix_hash::ObjectId::try_from(&oid[..])?)) } else { Ok(None) } @@ -118,8 +118,8 @@ impl CacheBackend for SledCacheBackend { fn write( &self, filter: Filter, - from: git2::Oid, - to: git2::Oid, + from: gix_hash::ObjectId, + to: gix_hash::ObjectId, _hint: HistoryGraphHint, _tree_keyed: bool, ) -> anyhow::Result<()> { diff --git a/josh-core/src/cache/stack.rs b/josh-core/src/cache/stack.rs index a0072c7b8..ce4cd23d7 100644 --- a/josh-core/src/cache/stack.rs +++ b/josh-core/src/cache/stack.rs @@ -31,8 +31,8 @@ impl CacheStack { pub fn write_all( &self, filter: filter::Filter, - from: git2::Oid, - to: git2::Oid, + from: gix_hash::ObjectId, + to: gix_hash::ObjectId, hint: HistoryGraphHint, tree_keyed: bool, ) -> anyhow::Result<()> { @@ -66,10 +66,10 @@ impl CacheStack { pub fn read_propagate( &self, filter: filter::Filter, - from: git2::Oid, + from: gix_hash::ObjectId, hint: HistoryGraphHint, tree_keyed: bool, - ) -> anyhow::Result> { + ) -> anyhow::Result> { let values = self .backends .iter() diff --git a/josh-core/src/cache/transaction.rs b/josh-core/src/cache/transaction.rs index 043d9f570..dde803552 100644 --- a/josh-core/src/cache/transaction.rs +++ b/josh-core/src/cache/transaction.rs @@ -10,7 +10,7 @@ use std::sync::{LazyLock, RwLock}; pub trait FilterHook { fn filter_for_commit( &self, - commit_oid: git2::Oid, + commit_oid: gix_hash::ObjectId, arg: &str, ) -> anyhow::Result; } @@ -21,9 +21,9 @@ pub struct Head { /// itself when detached. Either way, this is the ref to update to move HEAD. pub reference: String, /// The unpeeled target of [`Head::reference`], for guarding an update against it. - pub target: git2::Oid, + pub target: gix_hash::ObjectId, /// The commit HEAD resolves to, annotated tags peeled. - pub commit: git2::Oid, + pub commit: gix_hash::ObjectId, } impl Head { @@ -51,7 +51,7 @@ pub enum Expected { /// The ref must not exist yet. Absent, /// The ref must currently point at exactly this oid. - At(git2::Oid), + At(gix_hash::ObjectId), } /// Parse `refname` as the fully qualified name every ref API method requires it to be. @@ -81,32 +81,33 @@ fn previous_value(expected: Expected) -> gix::refs::transaction::PreviousValue { match expected { Expected::Any => PreviousValue::Any, Expected::Absent => PreviousValue::MustNotExist, - Expected::At(old) => PreviousValue::MustExistAndMatch(gix::refs::Target::Object( - crate::objects::gix_oid(old), - )), + Expected::At(old) => PreviousValue::MustExistAndMatch(gix::refs::Target::Object(old)), } } -static REF_CACHE: LazyLock>>> = - LazyLock::new(Default::default); +static REF_CACHE: LazyLock< + RwLock>>, +> = LazyLock::new(Default::default); -static POPULATE_MAP: LazyLock>> = - LazyLock::new(Default::default); +static POPULATE_MAP: LazyLock< + RwLock>, +> = LazyLock::new(Default::default); // Keyed by (input tree, pattern key, NFA state mask). The state mask makes entries independent // of the path a subtree was reached through; the legacy full-path fallback folds its root path // into a synthetic pattern key and uses mask 0. -static GLOB_MAP: LazyLock>> = - LazyLock::new(Default::default); +static GLOB_MAP: LazyLock< + RwLock>, +> = LazyLock::new(Default::default); // Path-projection memoization for `:PATHS` and its inverse, keyed by (input tree oid, root path). // Both are pure functions of the input tree, and workspace filters walk commits parent-first, so a // child commit reuses the projections its parent just computed for the subtrees they share, which // an in-process map captures. -static PATHS_MAP: LazyLock>> = +static PATHS_MAP: LazyLock>> = LazyLock::new(Default::default); -static INVERT_MAP: LazyLock>> = +static INVERT_MAP: LazyLock>> = LazyLock::new(Default::default); /// Placeholder hint for tree-keyed records with no commit context. Sequence 0 is @@ -239,23 +240,25 @@ impl TransactionContext { #[allow(unused)] struct Transaction2 { - commit_map: HashMap>, - apply_map: HashMap>, - subtract_map: HashMap<(git2::Oid, git2::Oid), git2::Oid>, - intersect_map: HashMap<(git2::Oid, git2::Oid), git2::Oid>, - overlay_map: HashMap<(git2::Oid, git2::Oid), git2::Oid>, - unapply_map: HashMap>, - legalize_map: HashMap<(crate::filter::Filter, git2::Oid), crate::filter::Filter>, - downstack_deps_map: HashMap>, - merge_trees_map: HashMap<(git2::Oid, git2::Oid, git2::Oid), git2::Oid>, - last_written_commit: Option<(git2::Oid, git2::Oid)>, + commit_map: HashMap>, + apply_map: HashMap>, + subtract_map: HashMap<(gix_hash::ObjectId, gix_hash::ObjectId), gix_hash::ObjectId>, + intersect_map: HashMap<(gix_hash::ObjectId, gix_hash::ObjectId), gix_hash::ObjectId>, + overlay_map: HashMap<(gix_hash::ObjectId, gix_hash::ObjectId), gix_hash::ObjectId>, + unapply_map: HashMap>, + legalize_map: HashMap<(crate::filter::Filter, gix_hash::ObjectId), crate::filter::Filter>, + downstack_deps_map: + HashMap>, + merge_trees_map: + HashMap<(gix_hash::ObjectId, gix_hash::ObjectId, gix_hash::ObjectId), gix_hash::ObjectId>, + last_written_commit: Option<(gix_hash::ObjectId, gix_hash::ObjectId)>, tree_cache: TreeCache, cache: std::sync::Arc, // In-transaction memoization of the trigram index (source tree -> index tree); the // cache backend behind it holds the durable, cross-transaction copy. - index_map: HashMap, - missing: Vec<(usize, crate::filter::Filter, git2::Oid)>, + index_map: HashMap, + missing: Vec<(usize, crate::filter::Filter, gix_hash::ObjectId)>, misses: usize, nesting_level: usize, } @@ -346,7 +349,7 @@ impl Transaction { // An ephemeral transaction must not contribute to a store that outlives it, so it // buffers privately and reads through to the shared one. - let objects_dir = josh_memodb::objects_dir(&repo); + let objects_dir = gix_repo.objects.path().to_owned(); let shared = josh_memodb::registry::shared(mem_odb_limit, &objects_dir); let mem_odb = if ephemeral { josh_memodb::MemOdb::chained(mem_odb_limit, objects_dir, shared) @@ -446,7 +449,7 @@ impl Transaction { pub fn read_tree_bytes( &self, odb: &josh_memodb::Odb, - oid: git2::Oid, + oid: gix_hash::ObjectId, ) -> anyhow::Result> { if let Some(bytes) = self.t2.borrow().tree_cache.get(oid) { return Ok(Some(TreeBytes::Cached(bytes))); @@ -588,7 +591,7 @@ impl Transaction { /// partial-name DWIM. `Ok(None)` if the ref (or the end of a symbolic chain) does not /// exist. The target is not peeled: for an annotated tag ref this is the tag oid, /// peeling is an object-store concern. - pub fn resolve_ref(&self, refname: &str) -> anyhow::Result> { + pub fn resolve_ref(&self, refname: &str) -> anyhow::Result> { // Parsing as a full name is what rejects `master`: gix's find would resolve it. let name = full_ref_name(refname)?; let Some(reference) = self.find_ref(&name)? else { @@ -623,12 +626,12 @@ impl Transaction { fn follow_symrefs( &self, mut reference: gix::refs::Reference, - ) -> anyhow::Result> { + ) -> anyhow::Result> { // git's own limit on how far a symbolic ref may point. for _ in 0..5 { let next = match &reference.target { gix::refs::Target::Object(id) => { - let target = crate::objects::git2_oid(id); + let target = id.to_owned(); return Ok(Some((reference.name, target))); } gix::refs::Target::Symbolic(next) => self.find_ref(next)?, @@ -682,10 +685,10 @@ impl Transaction { /// like `master~2` -- to the object it names, unpeeled. `Ok(None)` when it resolves to /// nothing, which covers both a malformed spec and one naming something absent: a /// revision a user typed is input, not a contract. - pub fn rev_parse(&self, spec: &str) -> anyhow::Result> { + pub fn rev_parse(&self, spec: &str) -> anyhow::Result> { let repo = self.repo(); match repo.rev_parse_single(spec) { - Ok(id) => Ok(Some(crate::objects::git2_oid(&id))), + Ok(id) => Ok(Some(id.into())), Err(gix::revision::spec::parse::single::Error::RangedRev { .. }) => Ok(None), Err(gix::revision::spec::parse::single::Error::Parse(error)) => { let operational_error = error.sources().any(|source| { @@ -792,7 +795,7 @@ impl Transaction { &self, refname: &str, expected: Expected, - target: git2::Oid, + target: gix_hash::ObjectId, log_message: &str, ) -> anyhow::Result<()> { let name = full_ref_name(refname)?; @@ -807,7 +810,7 @@ impl Transaction { }; if guard_allows_unchanged && let Some(reference) = self.find_ref(&name)? - && reference.target == gix::refs::Target::Object(crate::objects::gix_oid(target)) + && reference.target == gix::refs::Target::Object(target) { return Ok(()); } @@ -821,7 +824,7 @@ impl Transaction { message: log_message.into(), }, expected: previous_value(expected), - new: gix::refs::Target::Object(crate::objects::gix_oid(target)), + new: gix::refs::Target::Object(target), }, name, deref: false, @@ -914,9 +917,7 @@ impl Transaction { let name = full_ref_name(refname)?; if let Expected::At(old) = expected { match self.find_ref(&name)? { - Some(reference) - if reference.target - == gix::refs::Target::Object(crate::objects::gix_oid(old)) => {} + Some(reference) if reference.target == gix::refs::Target::Object(old) => {} _ => { return Err(anyhow!( "ref '{}' does not point at the expected value {old}", @@ -973,7 +974,7 @@ impl Transaction { pub fn for_each_ref_prefixed( &self, prefix: &str, - mut cb: impl FnMut(&str, git2::Oid) -> anyhow::Result<()>, + mut cb: impl FnMut(&str, gix_hash::ObjectId) -> anyhow::Result<()>, ) -> anyhow::Result<()> { // Glob metacharacters (*?[\) are all invalid in refnames, so a caller passing one // means to match, not to prefix. @@ -1008,7 +1009,7 @@ impl Transaction { if !name.starts_with(prefix) { continue; } - refs.insert(name.to_owned(), crate::objects::git2_oid(target)); + refs.insert(name.to_owned(), target.to_owned()); } for edit in self.pending_refs.borrow().iter() { let Ok(name) = std::str::from_utf8(edit.name.as_bstr()) else { @@ -1022,7 +1023,7 @@ impl Transaction { new: gix::refs::Target::Object(target), .. } => { - refs.insert(name.to_owned(), crate::objects::git2_oid(target)); + refs.insert(name.to_owned(), target.to_owned()); } gix::refs::transaction::Change::Update { new: gix::refs::Target::Symbolic(_), @@ -1053,7 +1054,12 @@ impl Transaction { prev } - pub fn insert_apply(&self, filter: crate::filter::Filter, from: git2::Oid, to: git2::Oid) { + pub fn insert_apply( + &self, + filter: crate::filter::Filter, + from: gix_hash::ObjectId, + to: gix_hash::ObjectId, + ) { let mut t2 = self.t2.borrow_mut(); t2.apply_map .entry(filter.id()) @@ -1061,7 +1067,11 @@ impl Transaction { .insert(from, to); } - pub fn get_apply(&self, filter: crate::filter::Filter, from: git2::Oid) -> Option { + pub fn get_apply( + &self, + filter: crate::filter::Filter, + from: gix_hash::ObjectId, + ) -> Option { let t2 = self.t2.borrow_mut(); if let Some(m) = t2.apply_map.get(&filter.id()) { return m.get(&from).cloned(); @@ -1071,7 +1081,7 @@ impl Transaction { pub(crate) fn insert_downstack_deps( &self, - oid: git2::Oid, + oid: gix_hash::ObjectId, deps: std::collections::HashSet, ) { let mut t2 = self.t2.borrow_mut(); @@ -1080,7 +1090,7 @@ impl Transaction { pub(crate) fn get_downstack_deps( &self, - oid: git2::Oid, + oid: gix_hash::ObjectId, ) -> Option> { let t2 = self.t2.borrow_mut(); t2.downstack_deps_map.get(&oid).cloned() @@ -1088,8 +1098,8 @@ impl Transaction { pub(crate) fn insert_merge_trees( &self, - key: (git2::Oid, git2::Oid, git2::Oid), - result: git2::Oid, + key: (gix_hash::ObjectId, gix_hash::ObjectId, gix_hash::ObjectId), + result: gix_hash::ObjectId, ) { let mut t2 = self.t2.borrow_mut(); t2.merge_trees_map.insert(key, result); @@ -1097,38 +1107,59 @@ impl Transaction { pub(crate) fn get_merge_trees( &self, - key: (git2::Oid, git2::Oid, git2::Oid), - ) -> Option { + key: (gix_hash::ObjectId, gix_hash::ObjectId, gix_hash::ObjectId), + ) -> Option { let t2 = self.t2.borrow_mut(); t2.merge_trees_map.get(&key).copied() } - pub fn insert_subtract(&self, from: (git2::Oid, git2::Oid), to: git2::Oid) { + pub fn insert_subtract( + &self, + from: (gix_hash::ObjectId, gix_hash::ObjectId), + to: gix_hash::ObjectId, + ) { let mut t2 = self.t2.borrow_mut(); t2.subtract_map.insert(from, to); } - pub fn get_subtract(&self, from: (git2::Oid, git2::Oid)) -> Option { + pub fn get_subtract( + &self, + from: (gix_hash::ObjectId, gix_hash::ObjectId), + ) -> Option { let t2 = self.t2.borrow_mut(); t2.subtract_map.get(&from).cloned() } - pub fn insert_intersect(&self, from: (git2::Oid, git2::Oid), to: git2::Oid) { + pub fn insert_intersect( + &self, + from: (gix_hash::ObjectId, gix_hash::ObjectId), + to: gix_hash::ObjectId, + ) { let mut t2 = self.t2.borrow_mut(); t2.intersect_map.insert(from, to); } - pub fn get_intersect(&self, from: (git2::Oid, git2::Oid)) -> Option { + pub fn get_intersect( + &self, + from: (gix_hash::ObjectId, gix_hash::ObjectId), + ) -> Option { let t2 = self.t2.borrow_mut(); t2.intersect_map.get(&from).cloned() } - pub fn insert_overlay(&self, from: (git2::Oid, git2::Oid), to: git2::Oid) { + pub fn insert_overlay( + &self, + from: (gix_hash::ObjectId, gix_hash::ObjectId), + to: gix_hash::ObjectId, + ) { let mut t2 = self.t2.borrow_mut(); t2.overlay_map.insert(from, to); } - pub fn get_overlay(&self, from: (git2::Oid, git2::Oid)) -> Option { + pub fn get_overlay( + &self, + from: (gix_hash::ObjectId, gix_hash::ObjectId), + ) -> Option { let t2 = self.t2.borrow_mut(); t2.overlay_map.get(&from).cloned() } @@ -1137,17 +1168,17 @@ impl Transaction { /// walk processes a commit right after writing its parent, so this single slot answers the /// common "what tree does my filtered parent have" lookup without re-parsing the parent from the /// odb -- and without retaining every written commit the way a map would. - pub fn set_last_written_commit(&self, commit: git2::Oid, tree: git2::Oid) { + pub fn set_last_written_commit(&self, commit: gix_hash::ObjectId, tree: gix_hash::ObjectId) { self.t2.borrow_mut().last_written_commit = Some((commit, tree)); } - pub fn last_written_commit(&self) -> Option<(git2::Oid, git2::Oid)> { + pub fn last_written_commit(&self) -> Option<(gix_hash::ObjectId, gix_hash::ObjectId)> { self.t2.borrow().last_written_commit } pub fn insert_legalize( &self, - from: (crate::filter::Filter, git2::Oid), + from: (crate::filter::Filter, gix_hash::ObjectId), to: crate::filter::Filter, ) { let mut t2 = self.t2.borrow_mut(); @@ -1156,13 +1187,18 @@ impl Transaction { pub fn get_legalize( &self, - from: (crate::filter::Filter, git2::Oid), + from: (crate::filter::Filter, gix_hash::ObjectId), ) -> Option { let t2 = self.t2.borrow_mut(); t2.legalize_map.get(&from).cloned() } - pub fn insert_unapply(&self, filter: crate::filter::Filter, from: git2::Oid, to: git2::Oid) { + pub fn insert_unapply( + &self, + filter: crate::filter::Filter, + from: gix_hash::ObjectId, + to: gix_hash::ObjectId, + ) { let mut t2 = self.t2.borrow_mut(); t2.unapply_map .entry(filter.id()) @@ -1170,25 +1206,24 @@ impl Transaction { .insert(from, to); } - pub fn insert_paths(&self, tree: (git2::Oid, String), result: git2::Oid) { + pub fn insert_paths(&self, tree: (gix_hash::ObjectId, String), result: gix_hash::ObjectId) { PATHS_MAP.write().unwrap().entry(tree).or_insert(result); } - pub fn get_paths(&self, tree: (git2::Oid, String)) -> Option { + pub fn get_paths(&self, tree: (gix_hash::ObjectId, String)) -> Option { PATHS_MAP.read().unwrap().get(&tree).cloned() } - pub fn insert_invert(&self, tree: (git2::Oid, String), result: git2::Oid) { + pub fn insert_invert(&self, tree: (gix_hash::ObjectId, String), result: gix_hash::ObjectId) { INVERT_MAP.write().unwrap().entry(tree).or_insert(result); } - pub fn get_invert(&self, tree: (git2::Oid, String)) -> Option { + pub fn get_invert(&self, tree: (gix_hash::ObjectId, String)) -> Option { INVERT_MAP.read().unwrap().get(&tree).cloned() } - /// Build a josh-search index cache that shards records by `commit`'s history-graph - /// position. Pass [`git2::Oid::ZERO_SHA1`] for a bare tree with no commit context. - pub fn trigram_index_cache(&self, commit: git2::Oid) -> TrigramIndexCache<'_> { + /// Cache indexes under the commit's history shard. A null ID means no commit context. + pub fn trigram_index_cache(&self, commit: gix_hash::ObjectId) -> TrigramIndexCache<'_> { TrigramIndexCache { transaction: self, hint: self.tree_keyed_hint(commit), @@ -1198,14 +1233,19 @@ impl Transaction { /// Trees carry no history position of their own, so tree-keyed records use the hint /// of the commit being indexed. Falls back to the placeholder when there is no commit /// context or the hint cannot be computed. - fn tree_keyed_hint(&self, commit: git2::Oid) -> HistoryGraphHint { - if commit == git2::Oid::ZERO_SHA1 { + fn tree_keyed_hint(&self, commit: gix_hash::ObjectId) -> HistoryGraphHint { + if commit == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { return TREE_KEYED_FALLBACK_HINT; } compute_history_hint(self, commit).unwrap_or(TREE_KEYED_FALLBACK_HINT) } - fn insert_trigram_index(&self, tree: git2::Oid, result: git2::Oid, hint: HistoryGraphHint) { + fn insert_trigram_index( + &self, + tree: gix_hash::ObjectId, + result: gix_hash::ObjectId, + hint: HistoryGraphHint, + ) { let filter = crate::filter::index(); let mut t2 = self.t2.borrow_mut(); t2.index_map.entry(tree).or_insert(result); @@ -1214,7 +1254,11 @@ impl Transaction { } } - fn get_trigram_index(&self, tree: git2::Oid, hint: HistoryGraphHint) -> Option { + fn get_trigram_index( + &self, + tree: gix_hash::ObjectId, + hint: HistoryGraphHint, + ) -> Option { let filter = crate::filter::index(); let t2 = self.t2.borrow_mut(); if let Some(oid) = t2.index_map.get(&tree).cloned() { @@ -1231,23 +1275,42 @@ impl Transaction { } } - pub fn insert_populate(&self, tree: (git2::Oid, git2::Oid), result: git2::Oid) { + pub fn insert_populate( + &self, + tree: (gix_hash::ObjectId, gix_hash::ObjectId), + result: gix_hash::ObjectId, + ) { POPULATE_MAP.write().unwrap().entry(tree).or_insert(result); } - pub fn get_populate(&self, tree: (git2::Oid, git2::Oid)) -> Option { + pub fn get_populate( + &self, + tree: (gix_hash::ObjectId, gix_hash::ObjectId), + ) -> Option { POPULATE_MAP.read().unwrap().get(&tree).cloned() } - pub fn insert_glob(&self, tree: (git2::Oid, git2::Oid, u64), result: git2::Oid) { + pub fn insert_glob( + &self, + tree: (gix_hash::ObjectId, gix_hash::ObjectId, u64), + result: gix_hash::ObjectId, + ) { GLOB_MAP.write().unwrap().entry(tree).or_insert(result); } - pub fn get_glob(&self, tree: (git2::Oid, git2::Oid, u64)) -> Option { + pub fn get_glob( + &self, + tree: (gix_hash::ObjectId, gix_hash::ObjectId, u64), + ) -> Option { GLOB_MAP.read().unwrap().get(&tree).cloned() } - pub fn insert_ref(&self, filter: crate::filter::Filter, from: git2::Oid, to: git2::Oid) { + pub fn insert_ref( + &self, + filter: crate::filter::Filter, + from: gix_hash::ObjectId, + to: gix_hash::ObjectId, + ) { REF_CACHE .write() .unwrap() @@ -1256,7 +1319,11 @@ impl Transaction { .insert(from, to); } - pub fn get_ref(&self, filter: crate::filter::Filter, from: git2::Oid) -> Option { + pub fn get_ref( + &self, + filter: crate::filter::Filter, + from: gix_hash::ObjectId, + ) -> Option { if let Some(m) = REF_CACHE.read().unwrap().get(&filter.id()) && let Some(oid) = m.get(&from) && self.odb().contains(*oid) @@ -1266,7 +1333,11 @@ impl Transaction { None } - pub fn get_unapply(&self, filter: crate::filter::Filter, from: git2::Oid) -> Option { + pub fn get_unapply( + &self, + filter: crate::filter::Filter, + from: gix_hash::ObjectId, + ) -> Option { let t2 = self.t2.borrow_mut(); if let Some(m) = t2.unapply_map.get(&filter.id()) { return m.get(&from).cloned(); @@ -1277,7 +1348,7 @@ impl Transaction { pub fn lookup_filter_hook( &self, hook: &str, - from: git2::Oid, + from: gix_hash::ObjectId, ) -> anyhow::Result { if let Some(h) = &self.filter_hook { return h.filter_for_commit(from, hook); @@ -1293,8 +1364,8 @@ impl Transaction { pub fn insert( &self, filter: crate::filter::Filter, - from: git2::Oid, - to: git2::Oid, + from: gix_hash::ObjectId, + to: gix_hash::ObjectId, store: bool, ) -> anyhow::Result<()> { let hint = if filter != crate::filter::sequence_number() @@ -1326,7 +1397,9 @@ impl Transaction { Ok(()) } - pub fn get_missing(&self) -> anyhow::Result> { + pub fn get_missing( + &self, + ) -> anyhow::Result> { let missing = self.t2.borrow().missing.clone(); let mut retained = Vec::with_capacity(missing.len()); for (level, f, i) in missing { @@ -1341,15 +1414,19 @@ impl Transaction { Ok(retained) } - pub fn known(&self, filter: crate::filter::Filter, from: git2::Oid) -> anyhow::Result { + pub fn known( + &self, + filter: crate::filter::Filter, + from: gix_hash::ObjectId, + ) -> anyhow::Result { Ok(self.get2(filter, from)?.is_some()) } pub fn get( &self, filter: crate::filter::Filter, - from: git2::Oid, - ) -> anyhow::Result> { + from: gix_hash::ObjectId, + ) -> anyhow::Result> { if let Some(x) = self.get2(filter, from)? { Ok(Some(x)) } else { @@ -1364,8 +1441,8 @@ impl Transaction { fn get2( &self, filter: crate::filter::Filter, - from: git2::Oid, - ) -> anyhow::Result> { + from: gix_hash::ObjectId, + ) -> anyhow::Result> { if filter.is_nop() { return Ok(Some(from)); } @@ -1391,7 +1468,7 @@ impl Transaction { let oid = t2.cache.read_propagate(filter, from, hint, false)?; if let Some(oid) = oid { - if oid == git2::Oid::ZERO_SHA1 { + if oid == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { return Ok(Some(oid)); } if filter == crate::filter::sequence_number() { @@ -1427,11 +1504,11 @@ pub struct TrigramIndexCache<'a> { } impl josh_search::IndexCache for TrigramIndexCache<'_> { - fn get_index(&self, tree: git2::Oid) -> Option { + fn get_index(&self, tree: gix_hash::ObjectId) -> Option { self.transaction.get_trigram_index(tree, self.hint) } - fn set_index(&self, tree: git2::Oid, index: git2::Oid) { + fn set_index(&self, tree: gix_hash::ObjectId, index: gix_hash::ObjectId) { self.transaction .insert_trigram_index(tree, index, self.hint) } @@ -1454,13 +1531,13 @@ mod tests { (dir, transaction) } - fn commit(transaction: &Transaction, msg: &str) -> git2::Oid { + fn commit(transaction: &Transaction, msg: &str) -> gix_hash::ObjectId { let repo = transaction.git2_repo(); let tree = repo .find_tree(repo.treebuilder(None).unwrap().write().unwrap()) .unwrap(); let sig = git2::Signature::new("t", "t@example.com", &git2::Time::new(0, 0)).unwrap(); - repo.commit(None, &sig, &sig, msg, &tree, &[]).unwrap() + crate::objects::gix_oid(repo.commit(None, &sig, &sig, msg, &tree, &[]).unwrap()) } #[test] @@ -1550,7 +1627,7 @@ mod tests { .find_reference("refs/heads/main") .unwrap() .target(), - Some(a) + Some(crate::objects::git2_oid(&a)) ); } @@ -1764,6 +1841,9 @@ mod tests { assert!(seen.is_empty()); } + // Windows cannot hold this ref at all: a device name is illegal as a path component, so + // an upstream whose path contains one cannot be mirrored there. + #[cfg(unix)] #[test] fn for_each_ref_prefixed_takes_a_prefix_no_worktree_could_hold() { // Upstream namespaces are percent-encoded repository paths, so a prefix component @@ -1788,6 +1868,8 @@ mod tests { assert_eq!(seen, ["refs/josh/upstream/aux/refs/heads/main"]); } + // Identifies the file by inode, so it only makes sense on unix. + #[cfg(unix)] #[test] fn update_ref_to_the_value_a_ref_already_has_writes_nothing() { use std::os::unix::fs::MetadataExt; @@ -1857,10 +1939,11 @@ mod tests { .find_tree(repo.treebuilder(None).unwrap().write().unwrap()) .unwrap(); let sig = git2::Signature::new("t", "t@example.com", &git2::Time::new(0, 0)).unwrap(); - let parent_commit = repo.find_commit(parent).unwrap(); - let tip = repo - .commit(None, &sig, &sig, "tip", &tree, &[&parent_commit]) - .unwrap(); + let parent_commit = repo.find_commit(crate::objects::git2_oid(&parent)).unwrap(); + let tip = crate::objects::gix_oid( + repo.commit(None, &sig, &sig, "tip", &tree, &[&parent_commit]) + .unwrap(), + ); transaction .update_ref("refs/heads/main", Expected::Any, tip, "test") .unwrap(); @@ -1940,7 +2023,11 @@ mod tests { let log = std::fs::read_to_string(dir.path().join(".git/logs/refs/heads/main")).unwrap(); let lines: Vec<&str> = log.lines().collect(); assert_eq!(lines.len(), 2, "{}", log); - assert!(lines[0].starts_with(&format!("{} {}", git2::Oid::ZERO_SHA1, a))); + assert!(lines[0].starts_with(&format!( + "{} {}", + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + a + ))); assert!(lines[0].ends_with("\tfirst")); assert!(lines[1].starts_with(&format!("{} {}", a, b))); assert!(lines[1].ends_with("\tsecond")); @@ -2207,16 +2294,22 @@ mod tests { ); let on_disk = git2::Repository::open(dir.path()).unwrap(); assert!(on_disk.find_reference("refs/josh/blob").is_err()); - assert!(on_disk.find_blob(oid).is_err()); + assert!(on_disk.find_blob(crate::objects::git2_oid(&oid)).is_err()); oid }; let on_disk = git2::Repository::open(dir.path()).unwrap(); assert_eq!( on_disk.find_reference("refs/josh/blob").unwrap().target(), - Some(oid) + Some(crate::objects::git2_oid(&oid)) + ); + assert_eq!( + on_disk + .find_blob(crate::objects::git2_oid(&oid)) + .unwrap() + .content(), + b"published" ); - assert_eq!(on_disk.find_blob(oid).unwrap().content(), b"published"); } /// An explicit disk-reader boundary has the same ordering as drop: object first, ref second. @@ -2234,9 +2327,15 @@ mod tests { let on_disk = git2::Repository::open(dir.path()).unwrap(); assert_eq!( on_disk.find_reference("refs/josh/blob").unwrap().target(), - Some(oid) + Some(crate::objects::git2_oid(&oid)) + ); + assert_eq!( + on_disk + .find_blob(crate::objects::git2_oid(&oid)) + .unwrap() + .content(), + b"boundary" ); - assert_eq!(on_disk.find_blob(oid).unwrap().content(), b"boundary"); } #[test] diff --git a/josh-core/src/cache/tree_cache.rs b/josh-core/src/cache/tree_cache.rs index 5f7c6399d..d8d35d720 100644 --- a/josh-core/src/cache/tree_cache.rs +++ b/josh-core/src/cache/tree_cache.rs @@ -18,29 +18,14 @@ impl std::ops::Deref for TreeBytes { } } -/// Cache key routing the oid digest into [`PassthroughHasher`]'s single-`write` path: -/// `git2::Oid`'s own `Hash` impl hashes its bytes as a slice, whose `write_usize` length -/// prefix the hasher rejects. -#[derive(PartialEq, Eq)] -struct OidKey(git2::Oid); +type OidMap = + std::collections::HashMap>; +type OidSet = std::collections::HashSet>; -impl std::hash::Hash for OidKey { - fn hash(&self, state: &mut H) { - state.write(self.0.as_bytes()); - } -} - -type OidMap = std::collections::HashMap>; -type OidSet = std::collections::HashSet>; - -/// Per-transaction cache of raw tree bytes, standing in for the parsed-object cache libgit2 -/// kept behind `find_tree` (which the gix-object tree ops no longer go through). See -/// [`super::Transaction::read_tree_bytes`] for the read path. +/// Raw tree bytes cached after their second read. /// -/// Policy: a tree is only copied into the cache on its second read -/// ([`should_promote`](Self::should_promote)), so once-read trees cost nothing extra; trees are -/// content-addressed so cached entries never go stale; the cache is dropped wholesale when it -/// outgrows [`LIMIT`](Self::LIMIT). +/// Entries never go stale because trees are content-addressed. The cache resets when it +/// exceeds [`LIMIT`](Self::LIMIT). #[derive(Default)] pub(crate) struct TreeCache { map: OidMap>, @@ -51,23 +36,22 @@ pub(crate) struct TreeCache { impl TreeCache { const LIMIT: usize = 64 * 1024 * 1024; - pub(crate) fn get(&self, oid: git2::Oid) -> Option> { - self.map.get(&OidKey(oid)).cloned() + pub(crate) fn get(&self, oid: gix_hash::ObjectId) -> Option> { + self.map.get(&oid).cloned() } - /// Whether `oid` is being read for the second time and should be copied into the cache - /// now; the first read is only recorded, so it can hand out the odb's buffer as it is. - pub(crate) fn should_promote(&mut self, oid: git2::Oid) -> bool { - !self.seen.insert(OidKey(oid)) + /// Return true from the second read onward. + pub(crate) fn should_promote(&mut self, oid: gix_hash::ObjectId) -> bool { + !self.seen.insert(oid) } - pub(crate) fn insert(&mut self, oid: git2::Oid, bytes: std::sync::Arc<[u8]>) { + pub(crate) fn insert(&mut self, oid: gix_hash::ObjectId, bytes: std::sync::Arc<[u8]>) { if self.bytes > Self::LIMIT { self.map.clear(); self.bytes = 0; } self.bytes += bytes.len(); - self.map.insert(OidKey(oid), bytes); + self.map.insert(oid, bytes); } } @@ -75,12 +59,10 @@ impl TreeCache { mod tests { use super::*; - fn oid(n: u8) -> git2::Oid { - git2::Oid::from_bytes(&[n; 20]).unwrap() + fn oid(n: u8) -> gix_hash::ObjectId { + gix_hash::ObjectId::from_bytes_or_panic(&[n; 20]) } - // The first read is only recorded; the second read promotes, and only then does the cache - // hold the bytes. #[test] fn promotes_on_second_read_only() { let mut cache = TreeCache::default(); @@ -91,8 +73,6 @@ mod tests { assert_eq!(&*cache.get(oid(1)).unwrap(), b"tree bytes"); } - // Growing past the budget drops the whole cache before the next insert, which itself - // stays cached. #[test] fn clears_wholesale_over_limit() { let mut cache = TreeCache::default(); diff --git a/josh-core/src/filter/mod.rs b/josh-core/src/filter/mod.rs index 580df6d1c..db69410db 100644 --- a/josh-core/src/filter/mod.rs +++ b/josh-core/src/filter/mod.rs @@ -5,6 +5,7 @@ use josh_filter::check_experimental_features_enabled; pub use josh_filter::experimental_features_enabled; use std::path::Path; +use std::str::FromStr; use std::sync::LazyLock; // Re-export from josh-filter @@ -23,18 +24,30 @@ pub use josh_filter::{as_file, pretty, spec}; pub mod text; pub mod tree; -pub fn as_tree(transaction: &cache::Transaction, filter: Filter) -> anyhow::Result { +pub fn as_tree( + transaction: &cache::Transaction, + filter: Filter, +) -> anyhow::Result { josh_filter::persist::as_tree(transaction.odb(), filter) } -pub fn from_tree(transaction: &cache::Transaction, tree_oid: git2::Oid) -> anyhow::Result { +pub fn from_tree( + transaction: &cache::Transaction, + tree_oid: gix_hash::ObjectId, +) -> anyhow::Result { josh_filter::persist::from_tree(transaction.odb(), tree_oid) } -static WORKSPACES: LazyLock>> = - LazyLock::new(Default::default); +static WORKSPACES: LazyLock< + std::sync::Mutex>, +> = LazyLock::new(Default::default); static ANCESTORS: LazyLock< - std::sync::Mutex>>, + std::sync::Mutex< + std::collections::HashMap< + gix_hash::ObjectId, + std::collections::HashSet, + >, + >, > = LazyLock::new(Default::default); /// Clear the process-global workspace and ancestor caches. @@ -68,26 +81,26 @@ pub enum SigRewrite { /// when (and only when) they need its contents. #[derive(Debug, Clone)] pub struct Rewrite { - tree: git2::Oid, - commit: git2::Oid, + tree: gix_hash::ObjectId, + commit: gix_hash::ObjectId, pub author: Option, pub committer: Option, pub message: Option, } impl Rewrite { - pub fn from_tree(tree: git2::Oid) -> Self { + pub fn from_tree(tree: gix_hash::ObjectId) -> Self { Rewrite { tree, author: None, - commit: git2::Oid::ZERO_SHA1, + commit: gix_hash::ObjectId::null(gix_hash::Kind::Sha1), committer: None, message: None, } } pub fn from_tree_with_metadata( - tree: git2::Oid, + tree: gix_hash::ObjectId, author: Option, committer: Option, message: Option, @@ -95,7 +108,7 @@ impl Rewrite { Rewrite { tree, author, - commit: git2::Oid::ZERO_SHA1, + commit: gix_hash::ObjectId::null(gix_hash::Kind::Sha1), committer, message, } @@ -137,11 +150,11 @@ impl Rewrite { } } - pub fn with_tree(self, tree: git2::Oid) -> Self { + pub fn with_tree(self, tree: gix_hash::ObjectId) -> Self { Rewrite { tree, ..self } } - pub fn tree_id(&self) -> git2::Oid { + pub fn tree_id(&self) -> gix_hash::ObjectId { self.tree } } @@ -209,11 +222,14 @@ fn lazy_refs2(op: &Op) -> Vec { lr } -pub fn resolve_refs(refs: &std::collections::HashMap, filter: Filter) -> Filter { +pub fn resolve_refs( + refs: &std::collections::HashMap, + filter: Filter, +) -> Filter { to_filter(resolve_refs2(refs, &to_op(filter))) } -fn resolve_refs2(refs: &std::collections::HashMap, op: &Op) -> Op { +fn resolve_refs2(refs: &std::collections::HashMap, op: &Op) -> Op { match op { Op::Compose(filters) => { Op::Compose(filters.iter().map(|f| resolve_refs(refs, *f)).collect()) @@ -356,9 +372,9 @@ fn propagate_meta(filter: Filter, meta: &std::collections::BTreeMap anyhow::Result { +) -> anyhow::Result { let filter = opt::optimize(filter); loop { let filtered = apply_to_commit2(filter, commit, transaction)?; @@ -413,7 +429,7 @@ fn resolve_workspace_redirect( fn get_workspace( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - tree: git2::Oid, + tree: gix_hash::ObjectId, reader: &tree::TreeReader, path: &Path, ) -> Filter { @@ -432,7 +448,7 @@ fn get_workspace( fn get_stored( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - tree: git2::Oid, + tree: gix_hash::ObjectId, reader: &tree::TreeReader, path: &Path, ) -> Filter { @@ -447,7 +463,7 @@ fn get_stored( fn get_starlark( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - tree: git2::Oid, + tree: gix_hash::ObjectId, reader: &tree::TreeReader, path: &Path, subfilter: Filter, @@ -473,7 +489,7 @@ fn get_starlark( fn get_filter( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - tree: git2::Oid, + tree: gix_hash::ObjectId, reader: &tree::TreeReader, path: &Path, ) -> Filter { @@ -481,7 +497,7 @@ fn get_filter( // One descent resolves both the WORKSPACES cache key (the workspace.josh entry oid) // and the blob to parse. let ws_id = match tree::get_path_entry_at(transaction, odb, reader, &ws_path) { - Ok(Some(entry)) => objects::git2_oid(&entry.oid), + Ok(Some(entry)) => entry.oid, _ => { return to_filter(Op::Empty); } @@ -518,7 +534,7 @@ fn read_josh_link( let link_entry = tree::get_path_entry_at(transaction, odb, reader, &link_path) .ok() .flatten()?; - let link_blob = tree::blob_bytes(odb, objects::git2_oid(&link_entry.oid))?; + let link_blob = tree::blob_bytes(odb, link_entry.oid)?; let b = std::str::from_utf8(&link_blob) .with_context(|| format!("invalid utf8 in {}", filename)) .ok()?; @@ -538,34 +554,34 @@ fn read_josh_link( fn get_rev_filter( transaction: &cache::Transaction, - commit_id: git2::Oid, + commit_id: gix_hash::ObjectId, filters: &[(RevMatch, LazyRef, Filter)], ) -> anyhow::Result { // First match wins - iterate in order for (match_op, filter_tip_ref, startfilter) in filters.iter() { let filter_tip = if let LazyRef::Resolved(filter_tip) = filter_tip_ref { - filter_tip + filter_tip.to_owned() } else { return Err(anyhow!("unresolved lazy ref")); }; - if match_op != &RevMatch::Default && !transaction.odb().contains(*filter_tip) { + if match_op != &RevMatch::Default && !transaction.odb().contains(filter_tip) { return Err(anyhow!("`:rev(...)` with nonexistent OID: {}", filter_tip)); } let matches = match match_op { RevMatch::AncestorStrict => { // `<` - matches if commit is ancestor of tip AND commit != tip (strict) - is_ancestor_of(transaction, commit_id, *filter_tip)? && commit_id != *filter_tip + is_ancestor_of(transaction, commit_id, filter_tip)? && commit_id != filter_tip } RevMatch::AncestorInclusive => { // `<=` - matches if commit is ancestor of tip OR commit == tip (inclusive) - is_ancestor_of(transaction, commit_id, *filter_tip)? + is_ancestor_of(transaction, commit_id, filter_tip)? } RevMatch::Equal => { // `==` - matches if commit == tip - commit_id == *filter_tip + commit_id == filter_tip } RevMatch::Default => { // `_` - always matches (makes filters after it unreachable) @@ -584,9 +600,9 @@ fn get_rev_filter( pub fn apply_to_commit2( filter: Filter, - commit_id: git2::Oid, + commit_id: gix_hash::ObjectId, transaction: &cache::Transaction, -) -> anyhow::Result> { +) -> anyhow::Result> { let op = peel_op(filter); if filter == Filter::new() { @@ -596,12 +612,12 @@ pub fn apply_to_commit2( // First match: oid-only fast paths, no object loads. The commit bytes are only read // after the memo gate below, so memo hits stay zero-I/O. match &op { - Op::Empty => return Ok(Some(git2::Oid::ZERO_SHA1)), + Op::Empty => return Ok(Some(gix_hash::ObjectId::null(gix_hash::Kind::Sha1))), Op::Chain(_) => { let mut current_oid = commit_id; for f in flatten_chain(filter) { - if current_oid == git2::Oid::ZERO_SHA1 { + if current_oid == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { break; } let r = some_or!(apply_to_commit2(f, current_oid, transaction)?, { @@ -628,7 +644,7 @@ pub fn apply_to_commit2( if let Some(oid) = transaction.get(filter, commit_id)? { return Ok(Some(oid)); } - let new_oid = downstack(transaction, commit_id, *base)?; + let new_oid = downstack(transaction, commit_id, base.to_owned())?; transaction.insert(filter, commit_id, new_oid, false)?; return Ok(Some(new_oid)); } @@ -727,7 +743,7 @@ pub fn apply_to_commit2( .collect::>>()? }; - let mut filtered_parent_ids: Vec = + let mut filtered_parent_ids: Vec = some_or!(filtered_parent_ids, { return Ok(None) }); // TODO: remove all parents that don't have a .link.josh @@ -758,7 +774,7 @@ pub fn apply_to_commit2( ".link.josh", ) { if let Some(commit_str) = link_file.get_meta("commit") { - if let Ok(commit_oid) = git2::Oid::from_str(&commit_str) { + if let Ok(commit_oid) = gix_hash::ObjectId::from_str(&commit_str) { if filtered_parent_ids.contains(&commit_oid) { while filtered_parent_ids[0] != commit_oid { filtered_parent_ids.rotate_right(1); @@ -788,13 +804,13 @@ pub fn apply_to_commit2( .collect::>>()? }; - let mut filtered_parent_ids: Vec = + let mut filtered_parent_ids: Vec = some_or!(filtered_parent_ids, { return Ok(None) }); let mut link_parents = vec![]; for (link_path, link_file) in find_link_files(odb, commit.tree_id()?)?.into_iter() { if let Some(commit_str) = link_file.get_meta("commit") { - if let Ok(commit_oid) = git2::Oid::from_str(&commit_str) { + if let Ok(commit_oid) = gix_hash::ObjectId::from_str(&commit_str) { if let Some(cmt) = transaction.get(to_filter(Op::Prefix(link_path)), commit_oid)? { @@ -870,7 +886,7 @@ pub fn apply_to_commit2( let normal_parents = commit .parent_ids() .map(|parent| transaction.get(filter, parent)) - .collect::>>>()?; + .collect::>>>()?; let normal_parents = some_or!(normal_parents, { return Ok(None) }); @@ -1005,7 +1021,7 @@ pub fn apply_to_commit2( let filtered_parent_ids = some_or!(filtered_parent_ids, { return Ok(None) }); - let trees: Vec = filtered_parent_ids + let trees: Vec = filtered_parent_ids .iter() .map(|x| history::filtered_parent_tree_id(transaction, *x)) .collect::>()?; @@ -1035,7 +1051,7 @@ pub fn apply_to_commit2( check_experimental_features_enabled("unapply filter")?; if let LazyRef::Resolved(target) = target { /* dbg!(target); */ - let target = objects::CommitData::read(odb, *target)?; + let target = objects::CommitData::read(odb, target.to_owned())?; // Only a root commit (no first parent) skips link detection; a // first parent that is present must be readable. if let Some(parent_id) = target.first_parent_id() { @@ -1053,7 +1069,7 @@ pub fn apply_to_commit2( ".link.josh", ) { if let Some(commit_str) = link.get_meta("commit") { - if let Ok(link_commit) = git2::Oid::from_str(&commit_str) { + if let Ok(link_commit) = gix_hash::ObjectId::from_str(&commit_str) { if commit.id() == link_commit { let unapply = to_filter(Op::Unapply(LazyRef::Resolved(parent.id()), *uf)); @@ -1088,7 +1104,7 @@ pub fn apply_to_commit2( let subdir = filter::invert(link.peel())?; let unapply = to_filter(Op::Unapply(LazyRef::Resolved(commit.id()), subdir)); if let Some(commit_str) = link.get_meta("commit") { - if let Ok(commit_oid) = git2::Oid::from_str(&commit_str) { + if let Ok(commit_oid) = gix_hash::ObjectId::from_str(&commit_str) { let r = some_or!(transaction.get(unapply, commit_oid)?, { return Ok(None); }); @@ -1097,7 +1113,7 @@ pub fn apply_to_commit2( } } } - return Ok(Some(git2::Oid::ZERO_SHA1)); + return Ok(Some(gix_hash::ObjectId::null(gix_hash::Kind::Sha1))); } _ => apply(transaction, filter, Rewrite::from_commit_data(&commit)?)?, @@ -1127,11 +1143,11 @@ pub fn apply_to_commit2( fn extract_submodule_commits( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - tree: git2::Oid, + tree: gix_hash::ObjectId, ) -> anyhow::Result< std::collections::BTreeMap< std::path::PathBuf, - (git2::Oid, crate::submodules::ParsedSubmoduleEntry), + (gix_hash::ObjectId, crate::submodules::ParsedSubmoduleEntry), >, > { use crate::submodules::{ParsedSubmoduleEntry, parse_gitmodules}; @@ -1162,7 +1178,7 @@ fn extract_submodule_commits( let mut submodule_commits: std::collections::BTreeMap< std::path::PathBuf, - (git2::Oid, ParsedSubmoduleEntry), + (gix_hash::ObjectId, ParsedSubmoduleEntry), > = std::collections::BTreeMap::new(); for parsed in submodule_entries { @@ -1171,7 +1187,7 @@ fn extract_submodule_commits( tree::get_path_entry_at(transaction, odb, &tree_reader, &submodule_path) { if entry.mode.is_commit() { - let commit_oid = objects::git2_oid(&entry.oid); + let commit_oid = entry.oid; submodule_commits.insert(submodule_path, (commit_oid, parsed)); } } @@ -1183,7 +1199,7 @@ fn extract_submodule_commits( fn get_link_roots( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - tree: git2::Oid, + tree: gix_hash::ObjectId, ) -> anyhow::Result> { let link_filter = to_filter(Op::pattern("**/.link.josh")?); let link_tree = apply_impl(transaction, odb, link_filter, Rewrite::from_tree(tree))?; @@ -1251,7 +1267,7 @@ fn apply_impl( Op::Message(m, r) => { // Rewriting a message leaves the tree alone, so with neither a commit nor a // message to transform this is identity, like the other history-only filters. - if x.commit == git2::Oid::ZERO_SHA1 && x.message.is_none() { + if x.commit == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) && x.message.is_none() { return Ok(x); } @@ -1309,8 +1325,8 @@ fn apply_impl( .ok() .flatten() }) - .map(|e| objects::git2_oid(&e.oid)) - .unwrap_or(git2::Oid::ZERO_SHA1) + .map(|e| e.oid) + .unwrap_or(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)) .to_string(), ), _ => None, @@ -1354,7 +1370,7 @@ fn apply_impl( odb, result_tree, std::path::Path::new(".gitmodules"), - git2::Oid::ZERO_SHA1, + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), 0o0100644, )?; } @@ -1369,7 +1385,7 @@ fn apply_impl( odb, tree, std::path::Path::new(".link.josh"), - git2::Oid::ZERO_SHA1, + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), 0o0100644, )?)) } @@ -1378,8 +1394,13 @@ fn apply_impl( use crate::link::find_link_files; let mut result_tree = x.tree; for (link_path, link_file) in find_link_files(odb, result_tree)?.iter() { - result_tree = - tree::insert_oid(odb, result_tree, link_path, git2::Oid::ZERO_SHA1, 0o0100644)?; + result_tree = tree::insert_oid( + odb, + result_tree, + link_path, + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + 0o0100644, + )?; // The link_file is already a filter with metadata, just serialize it let link_content = as_file(*link_file, 0); @@ -1407,7 +1428,7 @@ fn apply_impl( // Get commit from metadata let commit_oid = link_file .get_meta("commit") - .and_then(|s| git2::Oid::from_str(&s).ok()) + .and_then(|s| gix_hash::ObjectId::from_str(&s).ok()) .ok_or_else(|| anyhow!("Link file missing commit metadata"))?; let submodule_tree = git::read_tree_id(odb, commit_oid)?; @@ -1485,8 +1506,12 @@ fn apply_impl( // The kind comes from the header alone; a missing oid folds into the // "neither" arm below. InsertContent::Oid(oid) => match odb.try_kind(*oid) { - Ok(Some(gix_object::Kind::Blob)) => (*oid, git2::FileMode::Blob.into(), false), - Ok(Some(gix_object::Kind::Tree)) => (*oid, git2::FileMode::Tree.into(), true), + Ok(Some(gix_object::Kind::Blob)) => { + (oid.to_owned(), git2::FileMode::Blob.into(), false) + } + Ok(Some(gix_object::Kind::Tree)) => { + (oid.to_owned(), git2::FileMode::Tree.into(), true) + } _ => { return Err(anyhow::anyhow!( "insert: {} is neither a blob nor a tree", @@ -1519,8 +1544,11 @@ fn apply_impl( // entry; lookup failures fold into the fallback. let (file, mode) = match tree::get_path_entry(transaction, odb, x.tree_id(), source_path) { - Ok(Some(e)) => (objects::git2_oid(&e.oid), e.mode.value() as i32), - _ => (git2::Oid::ZERO_SHA1, git2::FileMode::Blob.into()), + Ok(Some(e)) => (e.oid, e.mode.value() as i32), + _ => ( + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + git2::FileMode::Blob.into(), + ), }; Ok(x.with_tree(tree::insert_oid( odb, @@ -1535,7 +1563,7 @@ fn apply_impl( // A missing path, a non-tree entry (e.g. a blob), or a failed lookup has no // subtree. let subtree = match tree::get_path_entry(transaction, odb, x.tree_id(), path) { - Ok(Some(entry)) if entry.mode.is_tree() => objects::git2_oid(&entry.oid), + Ok(Some(entry)) if entry.mode.is_tree() => entry.oid, _ => tree::empty_id(), }; Ok(x.with_tree(subtree)) @@ -1621,7 +1649,7 @@ fn apply_impl( } Op::ObjectRef(path) => { if let Ok(Some(entry)) = tree::get_path_entry(transaction, odb, x.tree_id(), path) { - let oid_str = objects::git2_oid(&entry.oid).to_string(); + let oid_str = entry.oid.to_string(); let blob_oid = odb.write(gix_object::Kind::Blob, oid_str.as_bytes()); Ok(x.with_tree(tree::insert_oid( odb, @@ -1641,7 +1669,7 @@ fn apply_impl( _ => return Ok(x), }; // Path exists: read OID string from blob content. - let oid_str = if let Some(blob) = tree::blob_bytes(odb, objects::git2_oid(&entry.oid)) { + let oid_str = if let Some(blob) = tree::blob_bytes(odb, entry.oid) { std::str::from_utf8(&blob)? .lines() .next() @@ -1651,7 +1679,7 @@ fn apply_impl( } else { String::new() }; - if let Ok(oid) = git2::Oid::from_str(&oid_str) { + if let Ok(oid) = gix_hash::ObjectId::from_str(&oid_str) { // Kind by header, never by `contains`: `read_header`'s disk fallback // virtualizes the empty tree, `exists` does not. let (oid, mode) = match odb.try_kind(oid) { @@ -1700,10 +1728,10 @@ fn apply_impl( Op::Unapply(target, uf) => { check_experimental_features_enabled("unapply filter")?; if let LazyRef::Resolved(target) = target { - let target = objects::CommitData::read(odb, *target)?; + let target = objects::CommitData::read(odb, target.to_owned())?; // The message must parse as an oid, so non-UTF-8 is an error. let target_msg = target.message()?; - let target = git2::Oid::from_str(std::str::from_utf8(target_msg)?)?; + let target = gix_hash::ObjectId::from_str(std::str::from_utf8(target_msg)?)?; let target_tree = git::read_tree_id(odb, target)?; /* dbg!(&uf); */ Ok(Rewrite::from_tree(filter::unapply( @@ -1733,10 +1761,10 @@ fn apply_impl( pub fn unapply( transaction: &cache::Transaction, filter: Filter, - tree: git2::Oid, - parent_tree: git2::Oid, - commits: Option<(git2::Oid, git2::Oid)>, -) -> anyhow::Result { + tree: gix_hash::ObjectId, + parent_tree: gix_hash::ObjectId, + commits: Option<(gix_hash::ObjectId, gix_hash::ObjectId)>, +) -> anyhow::Result { // A `:rev(...)` filter has no static inverse (`invert` returns `Err`, like `:workspace` / // `:stored` / `:starlark`), so this generic path is automatically skipped for it and it falls // through to the per-commit handler / chain recursion below. @@ -1824,9 +1852,9 @@ fn reverse_strip_overlay( transaction: &cache::Transaction, filter: Filter, original_filter: Filter, - tree: git2::Oid, - parent_tree: git2::Oid, -) -> anyhow::Result { + tree: gix_hash::ObjectId, + parent_tree: gix_hash::ObjectId, +) -> anyhow::Result { let filtered = apply( transaction, original_filter, @@ -1846,7 +1874,7 @@ fn reverse_strip_overlay( fn resolve_commit_filter( transaction: &cache::Transaction, op: &Op, - commit: git2::Oid, + commit: gix_hash::ObjectId, ) -> anyhow::Result { match op { Op::Rev(revs) => get_rev_filter(transaction, commit, revs), @@ -1864,10 +1892,10 @@ fn is_commit_resolved_filter(op: &Op) -> bool { fn unapply_per_rev_filter( transaction: &cache::Transaction, op: &Op, - tree: git2::Oid, - parent_tree: git2::Oid, - commits: Option<(git2::Oid, git2::Oid)>, -) -> anyhow::Result> { + tree: gix_hash::ObjectId, + parent_tree: gix_hash::ObjectId, + commits: Option<(gix_hash::ObjectId, gix_hash::ObjectId)>, +) -> anyhow::Result> { if is_commit_resolved_filter(op) { // `:rev`/`:hook` select their sub-filter from commit identity (mirroring the forward // `Op::Rev`/`Op::Hook` paths): resolve it for the commit being reconstructed and for the @@ -1972,8 +2000,8 @@ fn unapply_per_rev_filter( fn pre_process_tree( transaction: &cache::Transaction, - tree: git2::Oid, -) -> anyhow::Result { + tree: gix_hash::ObjectId, +) -> anyhow::Result { let odb = transaction.odb(); let path = Path::new("workspace.josh"); let ws_file = tree::get_blob(transaction, odb, tree, path); @@ -2006,7 +2034,7 @@ fn pre_process_tree( pub fn compute_warnings( transaction: &cache::Transaction, filter: Filter, - tree: git2::Oid, + tree: gix_hash::ObjectId, ) -> Vec { let mut warnings = Vec::new(); let mut filter = filter; @@ -2053,7 +2081,7 @@ pub fn compute_warnings( fn compute_warnings2( transaction: &cache::Transaction, filter: Filter, - tree: git2::Oid, + tree: gix_hash::ObjectId, ) -> Vec { let mut warnings = Vec::new(); @@ -2071,8 +2099,8 @@ fn compute_warnings2( /// Creates a cache for a given `tip` so repeated queries with the same `tip` are more efficient. pub fn is_ancestor_of( transaction: &cache::Transaction, - commit: git2::Oid, - tip: git2::Oid, + commit: gix_hash::ObjectId, + tip: gix_hash::ObjectId, ) -> anyhow::Result { if let Ok(tip_sequence_number) = cache::compute_sequence_number(transaction, tip) { if cache::compute_sequence_number(transaction, commit)? > tip_sequence_number { @@ -2139,7 +2167,7 @@ fn legalize_stored( t: &cache::Transaction, odb: &josh_memodb::Odb, f: Filter, - tree: git2::Oid, + tree: gix_hash::ObjectId, reader: &tree::TreeReader, ) -> anyhow::Result { if !needs_legalization(f) { @@ -2224,9 +2252,9 @@ fn legalize_stored( fn compute_splice_parents( transaction: &cache::Transaction, commit_filter: Filter, - parent_filters: Vec<(git2::Oid, Filter)>, + parent_filters: Vec<(gix_hash::ObjectId, Filter)>, meta: &std::collections::BTreeMap, -) -> anyhow::Result>> { +) -> anyhow::Result>> { // This is only reached when history splicing is enabled, and `per_rev_filter` rejects `:pin` // in that case, so the filters here are pin-free -- no pin handling is needed. let splice_parents = parent_filters @@ -2250,7 +2278,7 @@ fn compute_splice_parents( Ok(Some( splice_parents .into_iter() - .filter(|&oid| oid != git2::Oid::ZERO_SHA1) + .filter(|&oid| oid != gix_hash::ObjectId::null(gix_hash::Kind::Sha1)) .collect(), )) } @@ -2260,8 +2288,8 @@ fn per_rev_filter( commit: &objects::CommitData, filter: Filter, commit_filter: Filter, - parent_filters: Vec<(git2::Oid, Filter)>, -) -> anyhow::Result> { + parent_filters: Vec<(gix_hash::ObjectId, Filter)>, +) -> anyhow::Result> { // Propagate any meta-options from the outer filter (e.g. :~(gpgsig="norm-lf")[:rev(...)]) // into the per-commit filter so they are applied during commit rewriting. let meta = filter.into_meta(); @@ -2296,7 +2324,7 @@ fn per_rev_filter( let normal_parents = commit .parent_ids() .map(|parent| transaction.get(filter, parent)) - .collect::>>>()?; + .collect::>>>()?; let normal_parents = some_or!(normal_parents, { return Ok(None) }); // Special case: `:pin` filter needs to be aware of filtered history @@ -2337,7 +2365,7 @@ fn per_rev_filter( let mut target = None; for id in filtered_parent_ids .iter() - .filter(|x| **x != git2::Oid::ZERO_SHA1) + .filter(|x| **x != gix_hash::ObjectId::null(gix_hash::Kind::Sha1)) { let seq = cache::compute_sequence_number(transaction, *id)?; if target.map(|(top, _)| seq > top).unwrap_or(true) { @@ -2349,10 +2377,9 @@ fn per_rev_filter( // two preserved lineages join only inside the squashed region and one // of them would silently become unreachable. Fail instead. if let Some((_, target_id)) = target { - for id in filtered_parent_ids - .iter() - .filter(|x| **x != git2::Oid::ZERO_SHA1 && **x != target_id) - { + for id in filtered_parent_ids.iter().filter(|x| { + **x != gix_hash::ObjectId::null(gix_hash::Kind::Sha1) && **x != target_id + }) { if !objects::is_descendant_of(transaction.odb(), target_id, *id)? { return Err(anyhow!( "cannot squash {}: its filtered parents {} and {} do not descend \ @@ -2401,9 +2428,9 @@ fn per_rev_filter( /// onto the minimised base via a 3-way merge. Returns the new tip OID. pub fn downstack( transaction: &cache::Transaction, - change_oid: git2::Oid, - base_oid: git2::Oid, -) -> anyhow::Result { + change_oid: gix_hash::ObjectId, + base_oid: gix_hash::ObjectId, +) -> anyhow::Result { if !objects::is_descendant_of(transaction.odb(), change_oid, base_oid)? { return Err(anyhow!( "change {} is not a descendant of base {}", @@ -2532,10 +2559,10 @@ pub fn downstack( /// unrelated operations. fn cached_merge_trees( transaction: &cache::Transaction, - a: git2::Oid, - b: git2::Oid, - c: git2::Oid, -) -> anyhow::Result { + a: gix_hash::ObjectId, + b: gix_hash::ObjectId, + c: gix_hash::ObjectId, +) -> anyhow::Result { // Conventional 3-way identities. When the ancestor matches one side, the // merge result is the other side -- common in a clean linear stack where // the rebuilt parent's tree already equals the original intermediate's @@ -2591,7 +2618,7 @@ fn downstack_commit_deps( .first_parent_id() .map(|p| git::read_tree_id(odb, p)) .transpose()? - .unwrap_or(git2::Oid::ZERO_SHA1); + .unwrap_or(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)); let commit_tree_id = commit.tree_id()?; let mut deps = std::collections::HashSet::new(); @@ -2807,9 +2834,10 @@ mod tests { b.upsert(p, oid, git2::FileMode::Blob); } let empty = repo.treebuilder(None).unwrap().write().unwrap(); - let tree = b - .create_updated(&repo, &repo.find_tree(empty).unwrap()) - .unwrap(); + let tree = objects::gix_oid( + b.create_updated(&repo, &repo.find_tree(empty).unwrap()) + .unwrap(), + ); let cachestack = std::sync::Arc::new( cache::CacheStack::new().with_backend(cache::SledCacheBackend::new(td.path())), @@ -2817,7 +2845,7 @@ mod tests { let ctx = cache::TransactionContext::new(td.path(), cachestack); let t = ctx.open().unwrap(); - let apply_tree = |f: Filter| -> anyhow::Result { + let apply_tree = |f: Filter| -> anyhow::Result { Ok(apply(&t, f, Rewrite::from_tree(tree))?.tree_id()) }; @@ -2926,27 +2954,34 @@ mod tests { let td = tempfile::tempdir().unwrap(); let repo = git2::Repository::init_bare(td.path()).unwrap(); - let mk_tree = |files: &[(&str, &str)]| -> git2::Oid { + let mk_tree = |files: &[(&str, &str)]| -> gix_hash::ObjectId { let mut b = git2::build::TreeUpdateBuilder::new(); for (p, c) in files { let oid = repo.blob(c.as_bytes()).unwrap(); b.upsert(*p, oid, git2::FileMode::Blob); } let empty = repo.treebuilder(None).unwrap().write().unwrap(); - b.create_updated(&repo, &repo.find_tree(empty).unwrap()) - .unwrap() + objects::gix_oid( + b.create_updated(&repo, &repo.find_tree(empty).unwrap()) + .unwrap(), + ) }; let sig = git2::Signature::new("t", "t@e", &git2::Time::new(0, 0)).unwrap(); - let mk_commit = |tree: git2::Oid, parents: &[git2::Oid], msg: &str| -> git2::Oid { - let tree = repo.find_tree(tree).unwrap(); + let mk_commit = |tree: gix_hash::ObjectId, + parents: &[gix_hash::ObjectId], + msg: &str| + -> gix_hash::ObjectId { + let tree = repo.find_tree(objects::git2_oid(&tree)).unwrap(); let parent_commits: Vec = parents .iter() - .map(|p| repo.find_commit(*p).unwrap()) + .map(|p| repo.find_commit(objects::git2_oid(p)).unwrap()) .collect(); let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect(); - repo.commit(None, &sig, &sig, msg, &tree, &parent_refs) - .unwrap() + objects::gix_oid( + repo.commit(None, &sig, &sig, msg, &tree, &parent_refs) + .unwrap(), + ) }; // base <- side, and a merge of the two is the change being published. @@ -2965,24 +3000,24 @@ mod tests { let t = ctx.open().unwrap(); let out = repo - .find_commit(downstack(&t, merge, base).unwrap()) + .find_commit(objects::git2_oid(&downstack(&t, merge, base).unwrap())) .unwrap(); assert_eq!(out.parent_count(), 2, "merge change lost its second parent"); assert_eq!( out.parent_id(0).unwrap(), - base, + objects::git2_oid(&base), "first parent should be the minimal base" ); assert_eq!( out.parent_id(1).unwrap(), - side, + objects::git2_oid(&side), "second parent should be carried through" ); // A single-parent change is unaffected by the parent-preserving rewrite. let linear = mk_commit(mk_tree(&[("a", "1"), ("n", "n")]), &[base], "linear change"); let out_linear = repo - .find_commit(downstack(&t, linear, base).unwrap()) + .find_commit(objects::git2_oid(&downstack(&t, linear, base).unwrap())) .unwrap(); assert_eq!(out_linear.parent_count(), 1); } diff --git a/josh-core/src/filter/tree.rs b/josh-core/src/filter/tree.rs index 66de47b5f..21985d67d 100644 --- a/josh-core/src/filter/tree.rs +++ b/josh-core/src/filter/tree.rs @@ -4,9 +4,9 @@ use anyhow::anyhow; pub fn pathstree( root: &str, - input: git2::Oid, + input: gix_hash::ObjectId, transaction: &cache::Transaction, -) -> anyhow::Result { +) -> anyhow::Result { let odb = transaction.odb(); pathstree_inner(root, input, transaction, odb) } @@ -14,10 +14,10 @@ pub fn pathstree( /// Oid-level body of [`pathstree`]; the odb is hoisted like in [`remove_pred_inner`]. fn pathstree_inner( root: &str, - input: git2::Oid, + input: gix_hash::ObjectId, transaction: &cache::Transaction, odb: &josh_memodb::Odb, -) -> anyhow::Result { +) -> anyhow::Result { if let Some(cached) = transaction.get_paths((input, root.to_string())) { return Ok(cached); } @@ -33,7 +33,7 @@ fn pathstree_inner( if entry.mode.is_tree() { let s = pathstree_inner( &format!("{}{}{}", root, if root.is_empty() { "" } else { "/" }, name), - objects::git2_oid(entry.oid), + entry.oid.to_owned(), transaction, odb, )?; @@ -42,7 +42,7 @@ fn pathstree_inner( rebuild.keep(gix_object::tree::Entry { mode: gix_object::tree::EntryKind::Tree.into(), filename: entry.filename.to_owned(), - oid: objects::gix_oid(s), + oid: s, }); } } else if !entry.mode.is_commit() { @@ -50,18 +50,14 @@ fn pathstree_inner( let path = normalize_path(&Path::new(root).join(name)); let path_string = path.to_str().ok_or_else(|| anyhow!("no name"))?; let file_contents = if name == "workspace.josh" { - format!( - "#{}\n{}", - path_string, - blob_text(odb, objects::git2_oid(entry.oid)) - ) + format!("#{}\n{}", path_string, blob_text(odb, entry.oid.to_owned())) } else { path_string.to_string() }; rebuild.keep(gix_object::tree::Entry { mode: gix_object::tree::EntryKind::Blob.into(), filename: entry.filename.to_owned(), - oid: objects::gix_oid(odb.write(gix_object::Kind::Blob, file_contents.as_bytes())), + oid: odb.write(gix_object::Kind::Blob, file_contents.as_bytes()), }); } } @@ -71,23 +67,23 @@ fn pathstree_inner( } pub fn regex_replace( - input: git2::Oid, + input: gix_hash::ObjectId, regex: ®ex::Regex, replacement: &str, transaction: &cache::Transaction, -) -> anyhow::Result { +) -> anyhow::Result { let odb = transaction.odb(); regex_replace_inner(input, regex, replacement, transaction, odb) } /// Oid-level body of [`regex_replace`]; the odb is hoisted like in [`remove_pred_inner`]. fn regex_replace_inner( - input: git2::Oid, + input: gix_hash::ObjectId, regex: ®ex::Regex, replacement: &str, transaction: &cache::Transaction, odb: &josh_memodb::Odb, -) -> anyhow::Result { +) -> anyhow::Result { let bytes = transaction .read_tree_bytes(odb, input)? .ok_or_else(|| anyhow!("regex_replace: {} is not a tree", input))?; @@ -98,29 +94,24 @@ fn regex_replace_inner( // Non-UTF-8 entry names stay an error even though the name is otherwise unused here. std::str::from_utf8(entry.filename).map_err(|_| anyhow!("no name"))?; if entry.mode.is_tree() { - let s = regex_replace_inner( - objects::git2_oid(entry.oid), - regex, - replacement, - transaction, - odb, - )?; + let s = + regex_replace_inner(entry.oid.to_owned(), regex, replacement, transaction, odb)?; if s != tree::empty_id() { rebuild.keep(gix_object::tree::Entry { mode: entry.mode, filename: entry.filename.to_owned(), - oid: objects::gix_oid(s), + oid: s, }); } } else if !entry.mode.is_commit() { - let file_contents = blob_text(odb, objects::git2_oid(entry.oid)); + let file_contents = blob_text(odb, entry.oid.to_owned()); let replaced = regex.replacen(&file_contents, 0, replacement); rebuild.keep(gix_object::tree::Entry { mode: entry.mode, filename: entry.filename.to_owned(), - oid: objects::gix_oid(odb.write(gix_object::Kind::Blob, replaced.as_bytes())), + oid: odb.write(gix_object::Kind::Blob, replaced.as_bytes()), }); } } @@ -129,7 +120,7 @@ fn regex_replace_inner( /// The raw bytes of the blob `oid`, or `None` when the object is missing or not a blob -- /// `find_blob`'s tolerance, in facade currency. -pub fn blob_bytes(odb: &josh_memodb::Odb, oid: git2::Oid) -> Option { +pub fn blob_bytes(odb: &josh_memodb::Odb, oid: gix_hash::ObjectId) -> Option { match odb.read(oid) { Ok((gix_object::Kind::Blob, bytes)) => Some(bytes), _ => None, @@ -140,7 +131,7 @@ pub fn blob_bytes(odb: &josh_memodb::Odb, oid: git2::Oid) -> Option String { +pub(crate) fn blob_text(odb: &josh_memodb::Odb, oid: gix_hash::ObjectId) -> String { let bytes = some_or!(blob_bytes(odb, oid), { return "".to_owned(); }); @@ -212,7 +203,11 @@ impl TreeRebuild { /// Write the rebuilt tree, or return `input` unchanged when nothing was dropped or /// rewritten: an untouched entry set reproduces `input` bit-identically, since git trees /// are content-addressed and the write preserves entry order. - fn finish(self, odb: &josh_memodb::Odb, input: git2::Oid) -> anyhow::Result { + fn finish( + self, + odb: &josh_memodb::Odb, + input: gix_hash::ObjectId, + ) -> anyhow::Result { if self.changed { objects::write_tree_now(odb, self.out) } else { @@ -288,7 +283,7 @@ impl TreeReader { pub fn read_tree( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - oid: git2::Oid, + oid: gix_hash::ObjectId, ) -> anyhow::Result { let bytes = transaction .read_tree_bytes(odb, oid)? @@ -345,7 +340,7 @@ fn path_components(path: &Path) -> Option> { pub fn get_path_entry( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - root: git2::Oid, + root: gix_hash::ObjectId, path: &Path, ) -> anyhow::Result> { let bytes = match transaction.read_tree_bytes(odb, root)? { @@ -374,7 +369,7 @@ pub fn get_path_entry_at( if !entry.mode.is_tree() { return Ok(None); } - let bytes = match transaction.read_tree_bytes(odb, objects::git2_oid(&entry.oid))? { + let bytes = match transaction.read_tree_bytes(odb, entry.oid)? { Some(bytes) => bytes, None => return Ok(None), }; @@ -416,10 +411,10 @@ fn insert_in_order(out: &mut Vec, entry: gix_object::tr pub fn remove_pred( transaction: &cache::Transaction, path: &mut String, - input: git2::Oid, + input: gix_hash::ObjectId, pred: &dyn Fn(&str, bool) -> bool, - key: git2::Oid, -) -> anyhow::Result { + key: gix_hash::ObjectId, +) -> anyhow::Result { let odb = transaction.odb(); remove_pred_inner(transaction, odb, path, input, pred, key) } @@ -430,14 +425,11 @@ fn remove_pred_inner( transaction: &cache::Transaction, odb: &josh_memodb::Odb, path: &mut String, - input: git2::Oid, + input: gix_hash::ObjectId, pred: &dyn Fn(&str, bool) -> bool, - key: git2::Oid, -) -> anyhow::Result { - let root_key = git2::Oid::hash_object( - git2::ObjectType::Blob, - format!("glob-fallback:{:?}:{}", key, path).as_bytes(), - )?; + key: gix_hash::ObjectId, +) -> anyhow::Result { + let root_key = objects::hash_blob(format!("glob-fallback:{:?}:{}", key, path).as_bytes()); if let Some(cached) = transaction.get_glob((input, root_key, 0)) { return Ok(cached); } @@ -458,22 +450,15 @@ fn remove_pred_inner( path.push_str(name); if entry.mode.is_tree() { - let s = remove_pred_inner( - transaction, - odb, - path, - objects::git2_oid(entry.oid), - pred, - key, - )?; - if s != objects::git2_oid(entry.oid) || s == empty { + let s = remove_pred_inner(transaction, odb, path, entry.oid.to_owned(), pred, key)?; + if s != entry.oid.to_owned() || s == empty { rebuild.mark_changed(); } if s != empty { rebuild.keep(gix_object::tree::Entry { mode: entry.mode, filename: entry.filename.to_owned(), - oid: objects::gix_oid(s), + oid: s, }); } } else if entry.mode.is_commit() { @@ -512,11 +497,11 @@ pub use josh_filter::pattern::{CompiledPattern, PATTERN_MATCH_OPTIONS, PatternCo /// a literal prefix) stay separate. pub fn remove_pattern( transaction: &cache::Transaction, - input: git2::Oid, + input: gix_hash::ObjectId, cp: &CompiledPattern, - key: git2::Oid, + key: gix_hash::ObjectId, state: u64, -) -> anyhow::Result { +) -> anyhow::Result { let odb = transaction.odb(); remove_pattern_inner(transaction, odb, input, cp, key, state) } @@ -525,11 +510,11 @@ pub fn remove_pattern( fn remove_pattern_inner( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - input: git2::Oid, + input: gix_hash::ObjectId, cp: &CompiledPattern, - key: git2::Oid, + key: gix_hash::ObjectId, state: u64, -) -> anyhow::Result { +) -> anyhow::Result { let state = cp.closure(state); if let Some(cached) = transaction.get_glob((input, key, state)) { return Ok(cached); @@ -589,23 +574,16 @@ fn remove_pattern_inner( let s = if next == 0 { empty } else { - remove_pattern_inner( - transaction, - odb, - objects::git2_oid(entry.oid), - cp, - key, - next, - )? + remove_pattern_inner(transaction, odb, entry.oid.to_owned(), cp, key, next)? }; - if s != objects::git2_oid(entry.oid) || s == empty { + if s != entry.oid.to_owned() || s == empty { rebuild.mark_changed(); } if s != empty { rebuild.keep(gix_object::tree::Entry { mode: entry.mode, filename: entry.filename.to_owned(), - oid: objects::gix_oid(s), + oid: s, }); } } else if entry.mode.is_commit() { @@ -643,9 +621,9 @@ fn remove_pattern_inner( pub fn subtract( transaction: &cache::Transaction, - input1: git2::Oid, - input2: git2::Oid, -) -> anyhow::Result { + input1: gix_hash::ObjectId, + input2: gix_hash::ObjectId, +) -> anyhow::Result { let odb = transaction.odb(); subtract_inner(transaction, odb, input1, input2) } @@ -654,9 +632,9 @@ pub fn subtract( fn subtract_inner( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - input1: git2::Oid, - input2: git2::Oid, -) -> anyhow::Result { + input1: gix_hash::ObjectId, + input2: gix_hash::ObjectId, +) -> anyhow::Result { if input1 == input2 { return Ok(empty_id()); } @@ -681,17 +659,13 @@ fn subtract_inner( // Modifications are collected by name first and applied in one pass: `None` removes // the entry, `Some` replaces its oid with the subtraction result (only ever produced // for a tree entry, whose raw mode is kept). - let mut mods: std::collections::HashMap<&[u8], Option> = + let mut mods: std::collections::HashMap<&[u8], Option> = std::collections::HashMap::new(); for entry in &tree2.entries { if let Some(e1) = lookup_entry(&tree1, entry.filename, sorted1) { - let sub = subtract_inner( - transaction, - odb, - objects::git2_oid(e1.oid), - objects::git2_oid(entry.oid), - )?; - if sub == empty_id() || sub == git2::Oid::ZERO_SHA1 { + let sub = + subtract_inner(transaction, odb, e1.oid.to_owned(), entry.oid.to_owned())?; + if sub == empty_id() || sub == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { mods.insert(&**entry.filename, None); } else { mods.insert(&**entry.filename, Some(sub)); @@ -703,7 +677,7 @@ fn subtract_inner( out.retain(|e| mods.get(e.filename.as_slice()) != Some(&None)); for entry in &mut out { if let Some(Some(sub)) = mods.get(entry.filename.as_slice()) { - entry.oid = objects::gix_oid(*sub); + entry.oid = *sub; } } let result = objects::write_tree_now(odb, out)?; @@ -727,9 +701,9 @@ fn subtract_inner( /// set of paths out of a large tree therefore costs O(input2) instead of O(input1). pub fn intersect( transaction: &cache::Transaction, - input1: git2::Oid, - input2: git2::Oid, -) -> anyhow::Result { + input1: gix_hash::ObjectId, + input2: gix_hash::ObjectId, +) -> anyhow::Result { let odb = transaction.odb(); intersect_inner(transaction, odb, input1, input2) } @@ -738,9 +712,9 @@ pub fn intersect( fn intersect_inner( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - input1: git2::Oid, - input2: git2::Oid, -) -> anyhow::Result { + input1: gix_hash::ObjectId, + input2: gix_hash::ObjectId, +) -> anyhow::Result { // Identical (sub)trees intersect to themselves; an empty side leaves nothing to keep. if input1 == input2 { return Ok(input1); @@ -764,18 +738,14 @@ fn intersect_inner( let mut out = Vec::new(); for entry in &tree2.entries { if let Some(e1) = lookup_entry(&tree1, entry.filename, sorted1) { - let child = intersect( - transaction, - objects::git2_oid(e1.oid), - objects::git2_oid(entry.oid), - )?; - if child != empty_id() && child != git2::Oid::ZERO_SHA1 { + let child = intersect(transaction, e1.oid.to_owned(), entry.oid.to_owned())?; + if child != empty_id() && child != gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { insert_in_order( &mut out, gix_object::tree::Entry { mode: e1.mode, filename: entry.filename.to_owned(), - oid: objects::gix_oid(child), + oid: child, }, ); } @@ -795,14 +765,14 @@ fn intersect_inner( /// The raw bytes of a path component, for matching against tree entry names. fn component_bytes(c: &std::ffi::OsStr) -> &[u8] { - std::os::unix::ffi::OsStrExt::as_bytes(c) + josh_gix_ext::component_bytes(c) } /// Read `oid` as raw tree bytes, or `None` if it is missing or not a tree. Uncached: the /// insert path can be called without a transaction to hang the tree cache off. -fn tree_bytes(src: &impl gix_object::Find, oid: git2::Oid) -> Option> { +fn tree_bytes(src: &impl gix_object::Find, oid: gix_hash::ObjectId) -> Option> { let mut buf = Vec::new(); - match src.try_find(&objects::gix_oid(oid), &mut buf) { + match src.try_find(&oid, &mut buf) { Ok(Some(data)) if data.kind == gix_object::Kind::Tree => Some(buf), _ => None, } @@ -816,10 +786,10 @@ fn tree_bytes(src: &impl gix_object::Find, oid: git2::Oid) -> Option> { fn replace_child_inner( odb: &(impl gix_object::Find + gix_object::Write), child: &[u8], - oid: git2::Oid, + oid: gix_hash::ObjectId, mode: i32, - tree_oid: git2::Oid, -) -> anyhow::Result { + tree_oid: gix_hash::ObjectId, +) -> anyhow::Result { let mut out = match tree_bytes(odb, tree_oid) { Some(bytes) => seed_entries(&gix_object::TreeRef::from_bytes( &bytes, @@ -827,7 +797,7 @@ fn replace_child_inner( )?), None => Vec::new(), }; - let remove = oid == git2::Oid::ZERO_SHA1 || oid == empty_id(); + let remove = oid == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) || oid == empty_id(); let first = out.iter().position(|e| &*e.filename == child); out.retain(|e| &*e.filename != child); if !remove { @@ -835,7 +805,7 @@ fn replace_child_inner( mode: gix_object::tree::EntryMode::try_from(mode as u32) .map_err(|m| anyhow!("replace_child: invalid mode {:o}", m))?, filename: child.into(), - oid: objects::gix_oid(oid), + oid: oid, }; match first { Some(pos) => out.insert(pos, entry), @@ -850,11 +820,11 @@ fn replace_child_inner( /// overwritable. pub fn insert_oid( odb: &(impl gix_object::Find + gix_object::Write), - full_tree: git2::Oid, + full_tree: gix_hash::ObjectId, path: &Path, - oid: git2::Oid, + oid: gix_hash::ObjectId, mode: i32, -) -> anyhow::Result { +) -> anyhow::Result { let mut components = path.components(); let Some(first) = components.next() else { return Err(anyhow!("file_name")); @@ -881,7 +851,7 @@ pub fn insert_oid( let tree = gix_object::TreeRef::from_bytes(&bytes, gix_hash::Kind::Sha1)?; let sorted = entries_canonically_sorted(&tree); match lookup_entry(&tree, cb.into(), sorted) { - Some(e) => objects::git2_oid(e.oid), + Some(e) => e.oid.to_owned(), None => empty_id(), } } @@ -896,26 +866,23 @@ pub fn insert_oid( /// Kind of `oid`, or `None` when the object is missing (the zero oid included) or the /// header unreadable -- both read as an ordinary miss. -fn kind_of(src: &impl gix_object::FindHeader, oid: git2::Oid) -> Option { - src.try_header(&objects::gix_oid(oid)) - .ok() - .flatten() - .map(|h| h.kind) +fn kind_of(src: &impl gix_object::FindHeader, oid: gix_hash::ObjectId) -> Option { + src.try_header(&oid).ok().flatten().map(|h| h.kind) } /// Fill `buf` with the raw bytes of `oid` if it is a readable tree object. Parse failures /// fold to `None` at the caller, keeping the probe arms tolerant of corrupt trees. -fn read_tree_into(src: &impl gix_object::Find, oid: git2::Oid, buf: &mut Vec) -> bool { +fn read_tree_into(src: &impl gix_object::Find, oid: gix_hash::ObjectId, buf: &mut Vec) -> bool { matches!( - src.try_find(&objects::gix_oid(oid), buf), + src.try_find(&oid, buf), Ok(Some(data)) if data.kind == gix_object::Kind::Tree ) } pub fn diff_paths( src: &(impl gix_object::Find + gix_object::FindHeader), - input1: git2::Oid, - input2: git2::Oid, + input1: gix_hash::ObjectId, + input2: gix_hash::ObjectId, root: &str, ) -> anyhow::Result> { if input1 == input2 { @@ -955,15 +922,15 @@ pub fn diff_paths( if let Some(e) = tree1.entry(entry.filename) { r.append(&mut diff_paths( src, - objects::git2_oid(e.oid), - objects::git2_oid(entry.oid), + e.oid.to_owned(), + entry.oid.to_owned(), &format!("{}{}{}", root, if root.is_empty() { "" } else { "/" }, name), )?); } else { r.append(&mut diff_paths( src, - git2::Oid::ZERO_SHA1, - objects::git2_oid(entry.oid), + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + entry.oid.to_owned(), &format!("{}{}{}", root, if root.is_empty() { "" } else { "/" }, name), )?); } @@ -974,8 +941,8 @@ pub fn diff_paths( if tree2.entry(entry.filename).is_none() { r.append(&mut diff_paths( src, - objects::git2_oid(entry.oid), - git2::Oid::ZERO_SHA1, + entry.oid.to_owned(), + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), &format!("{}{}{}", root, if root.is_empty() { "" } else { "/" }, name), )?); } @@ -989,8 +956,8 @@ pub fn diff_paths( let name = std::str::from_utf8(entry.filename).map_err(|_| anyhow!("no name"))?; r.append(&mut diff_paths( src, - git2::Oid::ZERO_SHA1, - objects::git2_oid(entry.oid), + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + entry.oid.to_owned(), &format!("{}{}{}", root, if root.is_empty() { "" } else { "/" }, name), )?); } @@ -1002,8 +969,8 @@ pub fn diff_paths( let name = std::str::from_utf8(entry.filename).map_err(|_| anyhow!("no name"))?; r.append(&mut diff_paths( src, - objects::git2_oid(entry.oid), - git2::Oid::ZERO_SHA1, + entry.oid.to_owned(), + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), &format!("{}{}{}", root, if root.is_empty() { "" } else { "/" }, name), )?); } @@ -1015,9 +982,9 @@ pub fn diff_paths( pub fn overlay( transaction: &cache::Transaction, - input1: git2::Oid, - input2: git2::Oid, -) -> anyhow::Result { + input1: gix_hash::ObjectId, + input2: gix_hash::ObjectId, +) -> anyhow::Result { let odb = transaction.odb(); overlay_inner(transaction, odb, input1, input2) } @@ -1026,9 +993,9 @@ pub fn overlay( fn overlay_inner( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - input1: git2::Oid, - input2: git2::Oid, -) -> anyhow::Result { + input1: gix_hash::ObjectId, + input2: gix_hash::ObjectId, +) -> anyhow::Result { if let Some(cached) = transaction.get_overlay((input1, input2)) { return Ok(cached); } @@ -1052,17 +1019,12 @@ fn overlay_inner( // name exists in `tree1` (with `tree1` winning on blob collisions, keeping its raw // mode), taken over as-is otherwise -- placed at its canonical position, raw mode // included. - let mut mods: std::collections::HashMap<&[u8], git2::Oid> = + let mut mods: std::collections::HashMap<&[u8], gix_hash::ObjectId> = std::collections::HashMap::new(); let mut new_entries: Vec = Vec::new(); for entry in &tree2.entries { if let Some(e1) = lookup_entry(&tree1, entry.filename, sorted1) { - let id = overlay_inner( - transaction, - odb, - objects::git2_oid(e1.oid), - objects::git2_oid(entry.oid), - )?; + let id = overlay_inner(transaction, odb, e1.oid.to_owned(), entry.oid.to_owned())?; mods.insert(&**entry.filename, id); } else { new_entries.push((*entry).into()); @@ -1072,7 +1034,7 @@ fn overlay_inner( let mut out = seed_entries(&tree1); for entry in &mut out { if let Some(id) = mods.get(entry.filename.as_slice()) { - entry.oid = objects::gix_oid(*id); + entry.oid = *id; } } for entry in new_entries { @@ -1103,8 +1065,8 @@ pub fn invert_paths( transaction: &cache::Transaction, odb: &josh_memodb::Odb, root: &str, - tree: git2::Oid, -) -> anyhow::Result { + tree: gix_hash::ObjectId, +) -> anyhow::Result { if let Some(cached) = transaction.get_invert((tree, root.to_string())) { return Ok(cached); } @@ -1127,7 +1089,7 @@ pub fn invert_paths( // entry) -- resolved on the hoisted parse. let b = parsed .entry(entry.filename) - .map(|e| blob_text(odb, objects::git2_oid(e.oid))) + .map(|e| blob_text(odb, e.oid.to_owned())) .unwrap_or_default(); let opath = pathline(&b)?; @@ -1146,7 +1108,7 @@ pub fn invert_paths( transaction, odb, &format!("{}{}{}", root, if root.is_empty() { "" } else { "/" }, name), - objects::git2_oid(entry.oid), + entry.oid.to_owned(), )?; result = overlay(transaction, result, s)?; } @@ -1160,7 +1122,7 @@ pub fn invert_paths( pub fn original_path( transaction: &cache::Transaction, filter: Filter, - tree: git2::Oid, + tree: gix_hash::ObjectId, path: &Path, ) -> anyhow::Result { let paths_tree = apply( @@ -1175,9 +1137,9 @@ pub fn original_path( pub fn repopulated_tree( transaction: &cache::Transaction, filter: Filter, - full_tree: git2::Oid, - partial_tree: git2::Oid, -) -> anyhow::Result { + full_tree: gix_hash::ObjectId, + partial_tree: gix_hash::ObjectId, +) -> anyhow::Result { let paths_tree = apply( transaction, to_filter(Op::Paths).chain(filter), @@ -1192,9 +1154,9 @@ pub fn repopulated_tree( pub fn populate( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - paths: git2::Oid, - content: git2::Oid, -) -> anyhow::Result { + paths: gix_hash::ObjectId, + content: gix_hash::ObjectId, +) -> anyhow::Result { if let Some(cached) = transaction.get_populate((paths, content)) { return Ok(cached); } @@ -1223,12 +1185,7 @@ pub fn populate( result_tree = overlay( transaction, result_tree, - populate( - transaction, - odb, - objects::git2_oid(e.oid), - objects::git2_oid(entry.oid), - )?, + populate(transaction, odb, e.oid.to_owned(), entry.oid.to_owned())?, )?; } } @@ -1241,8 +1198,8 @@ pub fn populate( pub fn compose( transaction: &cache::Transaction, - trees: Vec<(&Filter, git2::Oid)>, -) -> anyhow::Result { + trees: Vec<(&Filter, gix_hash::ObjectId)>, +) -> anyhow::Result { let mut result = empty_id(); let mut taken = empty_id(); for (f, applied) in trees { @@ -1281,14 +1238,14 @@ pub fn compose( pub fn get_blob( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - tree: git2::Oid, + tree: gix_hash::ObjectId, path: &Path, ) -> String { let entry = match get_path_entry(transaction, odb, tree, path) { Ok(Some(entry)) => entry, _ => return "".to_owned(), }; - blob_text(odb, objects::git2_oid(&entry.oid)) + blob_text(odb, entry.oid) } /// [`get_blob`] over a caller-held parse of the root tree (see [`get_path_entry_at`]). @@ -1302,26 +1259,28 @@ pub(crate) fn get_blob_at( Ok(Some(entry)) => entry, _ => return "".to_owned(), }; - blob_text(odb, objects::git2_oid(&entry.oid)) + blob_text(odb, entry.oid) } -pub fn empty_id() -> git2::Oid { - git2::Oid::from_str("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap() +pub fn empty_id() -> gix_hash::ObjectId { + gix_hash::ObjectId::empty_tree(gix_hash::Kind::Sha1) } #[cfg(test)] mod tests { use super::*; - fn make_tree(repo: &git2::Repository, paths: &[&str]) -> git2::Oid { + fn make_tree(repo: &git2::Repository, paths: &[&str]) -> gix_hash::ObjectId { let mut b = git2::build::TreeUpdateBuilder::new(); for p in paths { - let oid = repo.blob(p.as_bytes()).unwrap(); - b.upsert(*p, oid, git2::FileMode::Blob); + let oid = objects::gix_oid(repo.blob(p.as_bytes()).unwrap()); + b.upsert(*p, objects::git2_oid(&oid), git2::FileMode::Blob); } let base = repo.treebuilder(None).unwrap().write().unwrap(); - b.create_updated(repo, &repo.find_tree(base).unwrap()) - .unwrap() + objects::gix_oid( + b.create_updated(repo, &repo.find_tree(base).unwrap()) + .unwrap(), + ) } fn open_transaction(td: &tempfile::TempDir) -> cache::Transaction { @@ -1339,20 +1298,22 @@ mod tests { let td = tempfile::tempdir().unwrap(); let repo = git2::Repository::init_bare(td.path()).unwrap(); - let blob = repo.blob(b"content").unwrap(); - let link = repo.blob(b"target").unwrap(); + let blob = objects::gix_oid(repo.blob(b"content").unwrap()); + let link = objects::gix_oid(repo.blob(b"target").unwrap()); // Gitlinks reference commits in other repositories; git does not require the oid // to exist locally. - let sub = git2::Oid::from_str("0123456789012345678901234567890123456789").unwrap(); + let sub = gix_hash::ObjectId::from_str("0123456789012345678901234567890123456789").unwrap(); let mut b = repo.treebuilder(None).unwrap(); - b.insert("keep.rs", blob, 0o100644).unwrap(); - b.insert("link.rs", link, 0o120000).unwrap(); - b.insert("sub", sub, 0o160000).unwrap(); - let input = b.write().unwrap(); + b.insert("keep.rs", objects::git2_oid(&blob), 0o100644) + .unwrap(); + b.insert("link.rs", objects::git2_oid(&link), 0o120000) + .unwrap(); + b.insert("sub", objects::git2_oid(&sub), 0o160000).unwrap(); + let input = objects::gix_oid(b.write().unwrap()); let t = open_transaction(&td); - let key = git2::Oid::from_str("1111111111111111111111111111111111111111").unwrap(); + let key = gix_hash::ObjectId::from_str("1111111111111111111111111111111111111111").unwrap(); let out = remove_pred(&t, &mut String::new(), input, &|_, isblob| isblob, key).unwrap(); assert_ne!(out, input, "dropping the gitlink must produce a new tree"); @@ -1364,7 +1325,7 @@ mod tests { assert!(out_entry(&out_tree, "keep.rs").is_some()); let link_entry = out_entry(&out_tree, "link.rs").expect("symlink kept"); assert_eq!(link_entry.mode.value(), 0o120000); - assert_eq!(objects::git2_oid(&link_entry.oid), link); + assert_eq!(link_entry.oid, link); } // The predicate must see full slash-separated paths at every depth (truncate discipline of @@ -1379,7 +1340,7 @@ mod tests { let input = make_tree(&repo, &paths); let t = open_transaction(&td); - let key = git2::Oid::from_str("2222222222222222222222222222222222222222").unwrap(); + let key = gix_hash::ObjectId::from_str("2222222222222222222222222222222222222222").unwrap(); let seen = std::cell::RefCell::new(Vec::new()); let pred = |path: &str, isblob: bool| { @@ -1408,14 +1369,18 @@ mod tests { .is_none() ); - let key2 = git2::Oid::from_str("3333333333333333333333333333333333333333").unwrap(); + let key2 = + gix_hash::ObjectId::from_str("3333333333333333333333333333333333333333").unwrap(); let out2 = remove_pred(&t, &mut String::new(), input, &|_, _| true, key2).unwrap(); assert_eq!(out2, input, "keep-everything must return the input oid"); } /// Read a tree the code under test produced: its result lives in the transaction's store, /// so it is read through the facade rather than the repository handle. - fn out_entries(t: &cache::Transaction, oid: git2::Oid) -> Vec { + fn out_entries( + t: &cache::Transaction, + oid: gix_hash::ObjectId, + ) -> Vec { objects::read_tree_entries(t.odb(), oid).unwrap() } @@ -1432,7 +1397,10 @@ mod tests { // Write a raw (unvalidated) tree object straight into the odb. This can express fsck-invalid // trees -- legacy filemodes, unsorted or duplicate entries, forbidden names -- that git can // still transport with default settings and that therefore reach remove_pred in production. - fn write_raw_tree(repo: &git2::Repository, entries: &[(&str, &str, git2::Oid)]) -> git2::Oid { + fn write_raw_tree( + repo: &git2::Repository, + entries: &[(&str, &str, gix_hash::ObjectId)], + ) -> gix_hash::ObjectId { let mut data = Vec::new(); for (mode, name, oid) in entries { data.extend_from_slice(mode.as_bytes()); @@ -1441,10 +1409,12 @@ mod tests { data.push(0); data.extend_from_slice(oid.as_bytes()); } - repo.odb() - .unwrap() - .write(git2::ObjectType::Tree, &data) - .unwrap() + objects::gix_oid( + repo.odb() + .unwrap() + .write(git2::ObjectType::Tree, &data) + .unwrap(), + ) } // A tree the filter keeps entirely round-trips byte-for-byte, no matter how fsck-invalid @@ -1454,8 +1424,8 @@ mod tests { fn remove_pred_preserves_fsck_invalid_trees() { let td = tempfile::tempdir().unwrap(); let repo = git2::Repository::init_bare(td.path()).unwrap(); - let blob = repo.blob(b"content").unwrap(); - let blob2 = repo.blob(b"other").unwrap(); + let blob = objects::gix_oid(repo.blob(b"content").unwrap()); + let blob2 = objects::gix_oid(repo.blob(b"other").unwrap()); let t = open_transaction(&td); let sub = write_raw_tree(&repo, &[("100644", "inner.rs", blob)]); @@ -1469,7 +1439,7 @@ mod tests { ("100644", "a.rs", blob2), ], ); - let key = git2::Oid::from_str("4444444444444444444444444444444444444444").unwrap(); + let key = gix_hash::ObjectId::from_str("4444444444444444444444444444444444444444").unwrap(); let out = remove_pred(&t, &mut String::new(), input, &|_, _| true, key).unwrap(); assert_eq!(out, input, "kept-entirely trees pass through verbatim"); } @@ -1480,8 +1450,8 @@ mod tests { fn remove_pred_rebuild_preserves_survivors() { let td = tempfile::tempdir().unwrap(); let repo = git2::Repository::init_bare(td.path()).unwrap(); - let blob = repo.blob(b"content").unwrap(); - let blob2 = repo.blob(b"other").unwrap(); + let blob = objects::gix_oid(repo.blob(b"content").unwrap()); + let blob2 = objects::gix_oid(repo.blob(b"other").unwrap()); let t = open_transaction(&td); let input = write_raw_tree( @@ -1503,7 +1473,7 @@ mod tests { ("100644", "a.rs", blob2), ], ); - let key = git2::Oid::from_str("5555555555555555555555555555555555555555").unwrap(); + let key = gix_hash::ObjectId::from_str("5555555555555555555555555555555555555555").unwrap(); let out = remove_pred( &t, &mut String::new(), @@ -1525,14 +1495,14 @@ mod tests { fn protected_names_are_not_special() { let td = tempfile::tempdir().unwrap(); let repo = git2::Repository::init_bare(td.path()).unwrap(); - let blob = repo.blob(b"content").unwrap(); + let blob = objects::gix_oid(repo.blob(b"content").unwrap()); let t = open_transaction(&td); let input = write_raw_tree( &repo, &[("100644", ".git", blob), ("100644", "keep.rs", blob)], ); - let key = git2::Oid::from_str("6666666666666666666666666666666666666666").unwrap(); + let key = gix_hash::ObjectId::from_str("6666666666666666666666666666666666666666").unwrap(); let out = remove_pred(&t, &mut String::new(), input, &|_, isblob| isblob, key).unwrap(); assert_eq!(out, input, ".git in input passes through verbatim"); @@ -1541,7 +1511,7 @@ mod tests { assert_eq!( get_path_entry(&t, odb, inserted, Path::new("sub/.git")) .unwrap() - .map(|e| objects::git2_oid(&e.oid)), + .map(|e| e.oid), Some(blob), "inserted names are written as given" ); @@ -1554,16 +1524,17 @@ mod tests { fn subtract_overlay_tolerate_non_canonical_order() { let td = tempfile::tempdir().unwrap(); let repo = git2::Repository::init_bare(td.path()).unwrap(); - let blob = repo.blob(b"content").unwrap(); - let blob2 = repo.blob(b"other").unwrap(); + let blob = objects::gix_oid(repo.blob(b"content").unwrap()); + let blob2 = objects::gix_oid(repo.blob(b"other").unwrap()); let t = open_transaction(&td); // "z.rs" first: canonically misplaced, so a bisect for it fails. let unsorted = write_raw_tree(&repo, &[("100644", "z.rs", blob), ("100644", "a.rs", blob)]); let mut b = repo.treebuilder(None).unwrap(); - b.insert("z.rs", blob, 0o100644).unwrap(); - let selector = b.write().unwrap(); + b.insert("z.rs", objects::git2_oid(&blob), 0o100644) + .unwrap(); + let selector = objects::gix_oid(b.write().unwrap()); let out = subtract(&t, unsorted, selector).unwrap(); let out_tree = out_entries(&t, out); @@ -1577,8 +1548,9 @@ mod tests { // the new entry lands after the last entry that canonically precedes it (here: at // the end, after a.rs). let mut b = repo.treebuilder(None).unwrap(); - b.insert("m.rs", blob2, 0o100644).unwrap(); - let addition = b.write().unwrap(); + b.insert("m.rs", objects::git2_oid(&blob2), 0o100644) + .unwrap(); + let addition = objects::gix_oid(b.write().unwrap()); let out = overlay(&t, unsorted, addition).unwrap(); let expected = write_raw_tree( &repo, @@ -1597,16 +1569,18 @@ mod tests { // Build a tree whose blob contents depend only on the entry NAME (not the full path), so // directories with identical children get identical subtree oids -- the precondition for // exercising the path-aliasing scenario. - fn make_named_tree(repo: &git2::Repository, paths: &[String]) -> git2::Oid { + fn make_named_tree(repo: &git2::Repository, paths: &[String]) -> gix_hash::ObjectId { let mut b = git2::build::TreeUpdateBuilder::new(); for p in paths { let name = p.rsplit('/').next().unwrap(); - let oid = repo.blob(name.as_bytes()).unwrap(); - b.upsert(p.as_str(), oid, git2::FileMode::Blob); + let oid = objects::gix_oid(repo.blob(name.as_bytes()).unwrap()); + b.upsert(p.as_str(), objects::git2_oid(&oid), git2::FileMode::Blob); } let base = repo.treebuilder(None).unwrap().write().unwrap(); - b.create_updated(repo, &repo.find_tree(base).unwrap()) - .unwrap() + objects::gix_oid( + b.create_updated(repo, &repo.find_tree(base).unwrap()) + .unwrap(), + ) } // Ground truth for a pattern filter: enumerate every blob path of `input` and keep exactly @@ -1614,9 +1588,13 @@ mod tests { // with a TreeUpdateBuilder (which drops empty dirs). Deliberately NOT remove_pred: the old // full-path walk had an order-dependent cache-aliasing bug for identical subtrees at // different paths, which the duplicated-subtree case below exercises. - fn ground_truth_tree(repo: &git2::Repository, input: git2::Oid, pattern: &str) -> git2::Oid { + fn ground_truth_tree( + repo: &git2::Repository, + input: gix_hash::ObjectId, + pattern: &str, + ) -> gix_hash::ObjectId { let glob = glob::Pattern::new(pattern).unwrap(); - let tree = repo.find_tree(input).unwrap(); + let tree = repo.find_tree(objects::git2_oid(&input)).unwrap(); let mut kept = vec![]; tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { if entry.kind() == Some(git2::ObjectType::Blob) { @@ -1633,8 +1611,10 @@ mod tests { b.upsert(path.as_str(), *oid, git2::FileMode::Blob); } let base = repo.treebuilder(None).unwrap().write().unwrap(); - b.create_updated(repo, &repo.find_tree(base).unwrap()) - .unwrap() + objects::gix_oid( + b.create_updated(repo, &repo.find_tree(base).unwrap()) + .unwrap(), + ) } // Property-style equivalence of the component-wise NFA walk against full-path glob matching: @@ -1705,7 +1685,7 @@ mod tests { let input = make_named_tree(&repo, &paths); - let tree = repo.find_tree(input).unwrap(); + let tree = repo.find_tree(objects::git2_oid(&input)).unwrap(); assert_eq!( tree.get_path(Path::new("a/x")).unwrap().id(), tree.get_path(Path::new("c/x")).unwrap().id(), @@ -1737,7 +1717,7 @@ mod tests { // Isolate the cases from each other (and from other tests in this process). cache::clear_global_caches(); - let key = git2::Oid::hash_object(git2::ObjectType::Blob, pattern.as_bytes()).unwrap(); + let key = objects::hash_blob(pattern.as_bytes()); let cp = CompiledPattern::compile(pattern).unwrap(); assert!(!cp.fallback, "`{pattern}` must not need the fallback"); let got = @@ -1755,7 +1735,7 @@ mod tests { // must produce ground-truth results with per-root cache keys. #[test] fn compiled_pattern_fallback_cases() { - let key = git2::Oid::from_str("1234567890123456789012345678901234567890").unwrap(); + let key = gix_hash::ObjectId::from_str("1234567890123456789012345678901234567890").unwrap(); // A '/' inside a bracket class never splits: it is inside the class token, not a // `Char('/')` token, so these stay on the NFA walk. @@ -1802,7 +1782,7 @@ mod tests { let repo = git2::Repository::init_bare(td.path()).unwrap(); let paths: Vec = ["a/f.txt", "b/f.txt"].map(String::from).to_vec(); let input = make_named_tree(&repo, &paths); - let tree = repo.find_tree(input).unwrap(); + let tree = repo.find_tree(objects::git2_oid(&input)).unwrap(); assert_eq!( tree.get_path(Path::new("a")).unwrap().id(), tree.get_path(Path::new("b")).unwrap().id() @@ -1810,7 +1790,7 @@ mod tests { let t = open_transaction(&td); let pattern = glob::Pattern::new("a/*.txt").unwrap(); - let key = git2::Oid::from_str("abcdef1234567890123456789012345678901234").unwrap(); + let key = gix_hash::ObjectId::from_str("abcdef1234567890123456789012345678901234").unwrap(); let out = remove_pred( &t, &mut String::new(), @@ -1831,24 +1811,30 @@ mod tests { fn get_path_entry_contract() { let td = tempfile::tempdir().unwrap(); let repo = git2::Repository::init_bare(td.path()).unwrap(); - let blob = repo.blob(b"content").unwrap(); + let blob = objects::gix_oid(repo.blob(b"content").unwrap()); let t = open_transaction(&td); let odb = t.odb(); - let gitlink = git2::Oid::from_str("0123456789012345678901234567890123456789").unwrap(); + let gitlink = + gix_hash::ObjectId::from_str("0123456789012345678901234567890123456789").unwrap(); let mut b = git2::build::TreeUpdateBuilder::new(); - b.upsert("a/b/deep.txt", blob, git2::FileMode::Blob); - b.upsert("top.txt", blob, git2::FileMode::Blob); - b.upsert("a/sub", gitlink, git2::FileMode::Commit); + b.upsert( + "a/b/deep.txt", + objects::git2_oid(&blob), + git2::FileMode::Blob, + ); + b.upsert("top.txt", objects::git2_oid(&blob), git2::FileMode::Blob); + b.upsert("a/sub", objects::git2_oid(&gitlink), git2::FileMode::Commit); let base = repo.treebuilder(None).unwrap().write().unwrap(); - let root = b - .create_updated(&repo, &repo.find_tree(base).unwrap()) - .unwrap(); + let root = objects::gix_oid( + b.create_updated(&repo, &repo.find_tree(base).unwrap()) + .unwrap(), + ); let entry = get_path_entry(&t, odb, root, Path::new("a/b/deep.txt")) .unwrap() .expect("hit at depth 2"); - assert_eq!(objects::git2_oid(&entry.oid), blob); + assert_eq!(entry.oid, blob); assert!(!entry.mode.is_tree()); let entry = get_path_entry(&t, odb, root, Path::new("a/sub")) @@ -1925,14 +1911,15 @@ mod tests { let td = tempfile::tempdir().unwrap(); let repo = git2::Repository::init_bare(td.path()).unwrap(); - let blob = repo.blob(b"legacy").unwrap(); + let blob = objects::gix_oid(repo.blob(b"legacy").unwrap()); let sub_tree = make_tree(&repo, &["dir/a.txt", "dir/b.txt"]); - let dir = repo - .find_tree(sub_tree) - .unwrap() - .get_name("dir") - .unwrap() - .id(); + let dir = objects::gix_oid( + repo.find_tree(objects::git2_oid(&sub_tree)) + .unwrap() + .get_name("dir") + .unwrap() + .id(), + ); let input1 = write_raw_tree( &repo, &[("40000", "dir", dir), ("100664", "legacy.rs", blob)], @@ -1948,10 +1935,7 @@ mod tests { 0o100664, "untouched entries must keep their raw mode, like the seeded treebuilder" ); - let out_dir = out_entries( - &t, - objects::git2_oid(&out_entry(&out_tree, "dir").unwrap().oid), - ); + let out_dir = out_entries(&t, out_entry(&out_tree, "dir").unwrap().oid); assert!( out_entry(&out_dir, "a.txt").is_none(), "matched path removed" @@ -1969,32 +1953,27 @@ mod tests { let td = tempfile::tempdir().unwrap(); let repo = git2::Repository::init_bare(td.path()).unwrap(); - let ours = repo.blob(b"ours").unwrap(); - let theirs = repo.blob(b"theirs").unwrap(); + let ours = objects::gix_oid(repo.blob(b"ours").unwrap()); + let theirs = objects::gix_oid(repo.blob(b"theirs").unwrap()); let input1 = write_raw_tree(&repo, &[("100664", "shared.rs", ours)]); let mut b = repo.treebuilder(None).unwrap(); - b.insert("new.rs", theirs, 0o100644).unwrap(); - b.insert("shared.rs", theirs, 0o100644).unwrap(); - let input2 = b.write().unwrap(); + b.insert("new.rs", objects::git2_oid(&theirs), 0o100644) + .unwrap(); + b.insert("shared.rs", objects::git2_oid(&theirs), 0o100644) + .unwrap(); + let input2 = objects::gix_oid(b.write().unwrap()); let t = open_transaction(&td); let out = overlay(&t, input1, input2).unwrap(); let out_tree = out_entries(&t, out); let shared = out_entry(&out_tree, "shared.rs").unwrap(); - assert_eq!( - objects::git2_oid(&shared.oid), - ours, - "input1 wins blob collisions" - ); + assert_eq!(shared.oid, ours, "input1 wins blob collisions"); assert_eq!( shared.mode.value(), 0o100664, "collision entries keep input1's raw mode" ); - assert_eq!( - objects::git2_oid(&out_entry(&out_tree, "new.rs").unwrap().oid), - theirs - ); + assert_eq!(out_entry(&out_tree, "new.rs").unwrap().oid, theirs); } } diff --git a/josh-core/src/git.rs b/josh-core/src/git.rs index 22c54f0cc..def25f308 100644 --- a/josh-core/src/git.rs +++ b/josh-core/src/git.rs @@ -1,6 +1,7 @@ use anyhow::{Context, anyhow}; use std::io::IsTerminal; use std::path::PathBuf; +use std::str::FromStr; /// Resolve the `input_ref` argument to a commit OID. /// @@ -13,7 +14,7 @@ use std::path::PathBuf; pub fn resolve_snapshot_input( transaction: &crate::cache::Transaction, input_ref: &str, -) -> anyhow::Result { +) -> anyhow::Result { let repo = transaction.git2_repo(); if input_ref == "+" || input_ref == "." { let mut index = repo.index()?; @@ -30,14 +31,18 @@ pub fn resolve_snapshot_input( let sig = crate::git::josh_commit_signature()?; let head_commit = repo.head()?.peel_to_commit()?; let commit_oid = repo.commit(None, &sig, &sig, "WIP", &tree, &[&head_commit])?; - Ok(commit_oid) - } else if let Ok(oid) = git2::Oid::from_str(input_ref) { - Ok(repo.find_object(oid, None)?.peel_to_commit()?.id()) + Ok(crate::objects::gix_oid(commit_oid)) + } else if let Ok(oid) = gix_hash::ObjectId::from_str(input_ref) { + Ok(crate::objects::gix_oid( + repo.find_object(crate::objects::git2_oid(&oid), None)? + .peel_to_commit()? + .id(), + )) } else { let obj = repo .revparse_single(input_ref) .with_context(|| format!("could not resolve input: {:?}", input_ref))?; - Ok(obj.peel_to_commit()?.id()) + Ok(crate::objects::gix_oid(obj.peel_to_commit()?.id())) } } @@ -247,7 +252,10 @@ impl GitCommand { /// lines via `gix_object::CommitRefIter`. This avoids libgit2's commit parse cache (a /// lock-guarded global that also decodes author/committer/message) whenever a caller only /// needs the parent ids; memory-store hits are zero-copy. -pub fn read_parent_ids(odb: &josh_memodb::Odb, oid: git2::Oid) -> anyhow::Result> { +pub fn read_parent_ids( + odb: &josh_memodb::Odb, + oid: gix_hash::ObjectId, +) -> anyhow::Result> { let (kind, bytes) = odb.read(oid)?; // A hard error, not an assert: this is reachable from inside git2 callback frames, // where unwinding across the FFI boundary would abort. @@ -258,15 +266,19 @@ pub fn read_parent_ids(odb: &josh_memodb::Odb, oid: git2::Oid) -> anyhow::Result kind )); } - gix_object::CommitRefIter::from_bytes(&bytes, gix_hash::Kind::Sha1) - .parent_ids() - .map(|p| Ok(git2::Oid::from_bytes(p.as_bytes())?)) - .collect() + Ok( + gix_object::CommitRefIter::from_bytes(&bytes, gix_hash::Kind::Sha1) + .parent_ids() + .collect(), + ) } /// Sibling of [`read_parent_ids`]: read a commit's tree OID without touching libgit2's /// commit parse cache. -pub fn read_tree_id(odb: &josh_memodb::Odb, oid: git2::Oid) -> anyhow::Result { +pub fn read_tree_id( + odb: &josh_memodb::Odb, + oid: gix_hash::ObjectId, +) -> anyhow::Result { let (kind, bytes) = odb.read(oid)?; // Same hard-error rationale as read_parent_ids. if kind != gix_object::Kind::Commit { @@ -276,8 +288,7 @@ pub fn read_tree_id(odb: &josh_memodb::Odb, oid: git2::Oid) -> anyhow::Result, flag: &str) -> bool { pub fn walk2( filter: filter::Filter, - input: git2::Oid, + input: gix_hash::ObjectId, transaction: &cache::Transaction, ) -> anyhow::Result<()> { if transaction.known(filter, input)? { @@ -69,16 +69,16 @@ fn find_unapply_base( // Used as a cache to avoid re-applying the filter to the same commit - // this function is called during revwalk so there be a lot of repeated // calls - filtered_to_original: &mut HashMap, + filtered_to_original: &mut HashMap, filter: filter::Filter, // When building the filtered_to_original mapping use this as a starting point // for the search for originals. If there are multiple originals that map to the // same filtered commit (which is common) use one that is reachable from contained_in. // Or, in other words, one that is contained in the history of contained_in. - contained_in: git2::Oid, + contained_in: gix_hash::ObjectId, // Filtered OID to compare against - filtered: git2::Oid, -) -> anyhow::Result { + filtered: gix_hash::ObjectId, +) -> anyhow::Result { // Consult the running map first: during an unapply walk we insert every // freshly created commit here, so a later commit can find its parent even // when there is no `contained_in` hint (e.g. a no-base push of an orphan @@ -89,13 +89,13 @@ fn find_unapply_base( return Ok(*original); } - if contained_in == git2::Oid::ZERO_SHA1 { + if contained_in == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { tracing::info!("contained in zero",); - return Ok(git2::Oid::ZERO_SHA1); + return Ok(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)); } let oid = filter::apply_to_commit(filter, contained_in, transaction)?; - if oid != git2::Oid::ZERO_SHA1 { + if oid != gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { filtered_to_original.insert(oid, contained_in); } @@ -110,7 +110,7 @@ fn find_unapply_base( let mut walk = objects::RevWalk::new(odb); walk.push(contained_in)?; - let mut result: Option<(git2::Oid, git2::Oid)> = None; + let mut result: Option<(gix_hash::ObjectId, gix_hash::ObjectId)> = None; let mut unlocked = HashSet::new(); let mut pending = HashSet::new(); unlocked.insert(contained_in); @@ -152,7 +152,7 @@ fn find_unapply_base( } None => { tracing::info!("Didn't find original",); - Ok(git2::Oid::ZERO_SHA1) + Ok(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)) } } } @@ -160,12 +160,12 @@ fn find_unapply_base( pub fn find_original( transaction: &cache::Transaction, filter: filter::Filter, - contained_in: git2::Oid, - filtered: git2::Oid, + contained_in: gix_hash::ObjectId, + filtered: gix_hash::ObjectId, linear: bool, -) -> anyhow::Result { - if contained_in == git2::Oid::ZERO_SHA1 { - return Ok(git2::Oid::ZERO_SHA1); +) -> anyhow::Result { + if contained_in == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { + return Ok(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)); } if filter.is_nop() { return Ok(filtered); @@ -191,7 +191,7 @@ pub fn find_original( } } - Ok(git2::Oid::ZERO_SHA1) + Ok(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)) } // takes everything from base except its tree and replaces it with the tree @@ -199,10 +199,10 @@ pub fn find_original( pub fn rewrite_commit( odb: &josh_memodb::Odb, base: &objects::CommitData, - parents: &[git2::Oid], + parents: &[gix_hash::ObjectId], rewrite_data: filter::Rewrite, gpgsig: GpgsigMode, -) -> anyhow::Result { +) -> anyhow::Result { use gix_object::bstr::BString; // gix_object::CommitRef uses byte strings for Oids, but in hex representation, not raw bytes. @@ -301,8 +301,8 @@ pub fn rewrite_commit( fn find_oldest_similar_commit( transaction: &cache::Transaction, filter: filter::Filter, - unfiltered: git2::Oid, -) -> anyhow::Result { + unfiltered: gix_hash::ObjectId, +) -> anyhow::Result { let odb = transaction.odb(); let mut walk = objects::RevWalk::new(odb); walk.push(unfiltered)?; @@ -323,12 +323,12 @@ fn find_oldest_similar_commit( fn find_new_branch_base( transaction: &cache::Transaction, - filtered_to_original: &mut HashMap, + filtered_to_original: &mut HashMap, filter: filter::Filter, // See "contained_in" in find_unapply_base - contained_in: git2::Oid, - filtered: git2::Oid, -) -> anyhow::Result { + contained_in: gix_hash::ObjectId, + filtered: gix_hash::ObjectId, +) -> anyhow::Result { let odb = transaction.odb(); let mut walk = objects::RevWalk::new(odb); walk.push(filtered)?; @@ -338,7 +338,7 @@ fn find_new_branch_base( for rev in walk.into_topo_vec(|_| false)? { if let Ok(base) = find_unapply_base(transaction, filtered_to_original, filter, contained_in, rev) - && base != git2::Oid::ZERO_SHA1 + && base != gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { tracing::info!("new branch base: {:?} mapping to {:?}", base, rev); let base = if let Ok(new_base) = find_oldest_similar_commit(transaction, filter, base) { @@ -354,7 +354,7 @@ fn find_new_branch_base( } } tracing::info!("new branch base not found"); - Ok(git2::Oid::ZERO_SHA1) + Ok(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)) } #[derive(Clone, Debug)] @@ -368,16 +368,16 @@ pub enum OrphansMode { pub fn unapply_filter( transaction: &cache::Transaction, filter: filter::Filter, - original_target: git2::Oid, - old_filtered_oid: git2::Oid, - new_filtered_oid: git2::Oid, + original_target: gix_hash::ObjectId, + old_filtered_oid: gix_hash::ObjectId, + new_filtered_oid: gix_hash::ObjectId, orphans_mode: OrphansMode, - reparent_orphans: Option, -) -> anyhow::Result { + reparent_orphans: Option, +) -> anyhow::Result { let mut filtered_to_original = HashMap::new(); let mut ret = original_target; - let old_filtered_oid = if old_filtered_oid == git2::Oid::ZERO_SHA1 { + let old_filtered_oid = if old_filtered_oid == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { match find_new_branch_base( transaction, &mut filtered_to_original, @@ -470,7 +470,7 @@ pub fn unapply_filter( OrphansMode::Fail => { return Err(anyhow!(indoc::formatdoc!( r###" - Rejecting new orphan branch at {:?} ({:?}) + Rejecting new orphan branch at {:?} ({}) Specify one of these options: '-o allow_orphans' to keep the history as is '-o merge' to import new history by creating merge commit @@ -497,7 +497,7 @@ pub fn unapply_filter( }) .filter(|unapply_base| { if let Ok(oid) = unapply_base { - *oid != git2::Oid::ZERO_SHA1 + *oid != gix_hash::ObjectId::null(gix_hash::Kind::Sha1) } else { true } @@ -555,7 +555,7 @@ pub fn unapply_filter( Ok(new_trees) => new_trees, Err(e) => { return Err(anyhow!( - "\nCan't apply {:?} ({:?})\n{}", + "\nCan't apply {:?} ({})\n{}", commit_message, module_commit.id(), e @@ -594,7 +594,7 @@ pub fn unapply_filter( // This will typically be parent_count == 2 and mean we are dealing with a merge // where the parents have differences outside of the filter. parent_count => { - let mut tid = git2::Oid::ZERO_SHA1; + let mut tid = gix_hash::ObjectId::null(gix_hash::Kind::Sha1); for i in 0..parent_count { // If one of the parents is a descendant of the target branch and the other is // not, pick the tree of the one that is a descendant. @@ -610,7 +610,7 @@ pub fn unapply_filter( } } - if tid == git2::Oid::ZERO_SHA1 && parent_count == 2 { + if tid == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) && parent_count == 2 { // If we could not select one of the parents, try to merge them. // We expect conflicts to occur only in the paths that are present in // the filtered commit. @@ -657,7 +657,7 @@ pub fn unapply_filter( } } - if tid == git2::Oid::ZERO_SHA1 { + if tid == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { // We give up. If we see this message again we need to investigate once // more and maybe consider allowing a manual override as last resort. tracing::warn!("rejecting merge"); @@ -679,7 +679,7 @@ pub fn unapply_filter( let apply = filter::Rewrite::from_tree(new_tree); - let original_parent_oids: Vec = + let original_parent_oids: Vec = original_parents.iter().map(|c| c.id()).collect(); ret = rewrite_commit( odb, @@ -711,9 +711,9 @@ pub fn unapply_filter( fn select_parent_commits( odb: &josh_memodb::Odb, original_commit: &objects::CommitData, - filtered_tree_id: git2::Oid, - filtered_parents: &[(git2::Oid, git2::Oid)], -) -> anyhow::Result> { + filtered_tree_id: gix_hash::ObjectId, + filtered_parents: &[(gix_hash::ObjectId, gix_hash::ObjectId)], +) -> anyhow::Result> { let affects_filtered = filtered_parents .iter() .any(|(_, tree_id)| filtered_tree_id != *tree_id); @@ -737,15 +737,15 @@ fn select_parent_commits( // parents={none, linear, keep-trivial, default} pub fn drop_commit( - original_commit: git2::Oid, - filtered_parent_ids: Vec, + original_commit: gix_hash::ObjectId, + filtered_parent_ids: Vec, transaction: &cache::Transaction, filter: filter::Filter, -) -> anyhow::Result { +) -> anyhow::Result { let r = if let Some(id) = filtered_parent_ids.first() { *id } else { - git2::Oid::ZERO_SHA1 + gix_hash::ObjectId::null(gix_hash::Kind::Sha1) }; transaction.insert(filter, original_commit, r, false)?; @@ -755,12 +755,12 @@ pub fn drop_commit( pub fn create_filtered_commit_with_meta( original_commit: &objects::CommitData, - filtered_parent_ids: Vec, + filtered_parent_ids: Vec, rewrite_data: filter::Rewrite, transaction: &cache::Transaction, filter: filter::Filter, meta: std::collections::BTreeMap, -) -> anyhow::Result { +) -> anyhow::Result { let (r, is_new) = create_filtered_commit2( transaction, original_commit, @@ -778,11 +778,11 @@ pub fn create_filtered_commit_with_meta( pub fn create_filtered_commit( original_commit: &objects::CommitData, - filtered_parent_ids: Vec, + filtered_parent_ids: Vec, rewrite_data: filter::Rewrite, transaction: &cache::Transaction, filter: filter::Filter, -) -> anyhow::Result { +) -> anyhow::Result { create_filtered_commit_with_meta( original_commit, filtered_parent_ids, @@ -796,14 +796,14 @@ pub fn create_filtered_commit( fn create_filtered_commit2( transaction: &cache::Transaction, original_commit: &objects::CommitData, - filtered_parent_ids: Vec, + filtered_parent_ids: Vec, rewrite_data: filter::Rewrite, options: BTreeMap, -) -> anyhow::Result<(git2::Oid, bool)> { +) -> anyhow::Result<(gix_hash::ObjectId, bool)> { let odb = transaction.odb(); - let mut filtered_parents: Vec<(git2::Oid, git2::Oid)> = filtered_parent_ids + let mut filtered_parents: Vec<(gix_hash::ObjectId, gix_hash::ObjectId)> = filtered_parent_ids .iter() - .filter(|x| **x != git2::Oid::ZERO_SHA1) + .filter(|x| **x != gix_hash::ObjectId::null(gix_hash::Kind::Sha1)) .map(|x| Ok((*x, filtered_parent_tree_id(transaction, *x)?))) .collect::>()?; @@ -819,7 +819,7 @@ fn create_filtered_commit2( let nonzero_parent_ids: Vec<_> = filtered_parent_ids .iter() .copied() - .filter(|x| *x != git2::Oid::ZERO_SHA1) + .filter(|x| *x != gix_hash::ObjectId::null(gix_hash::Kind::Sha1)) .collect(); let is_initial_merge = nonzero_parent_ids.len() > 1 && !cache::parents_share_root(transaction, &nonzero_parent_ids)?; @@ -859,7 +859,7 @@ fn create_filtered_commit2( } } - let selected_filtered_parent_ids: Vec = select_parent_commits( + let selected_filtered_parent_ids: Vec = select_parent_commits( odb, original_commit, rewrite_data.tree_id(), @@ -875,7 +875,7 @@ fn create_filtered_commit2( return Ok((filtered_parents[0].0, false)); } if rewrite_data.tree_id() == filter::tree::empty_id() { - return Ok((git2::Oid::ZERO_SHA1, false)); + return Ok((gix_hash::ObjectId::null(gix_hash::Kind::Sha1), false)); } } @@ -905,8 +905,8 @@ fn create_filtered_commit2( /// parsing the commit from the odb. pub(crate) fn filtered_parent_tree_id( transaction: &cache::Transaction, - oid: git2::Oid, -) -> anyhow::Result { + oid: gix_hash::ObjectId, +) -> anyhow::Result { if let Some((last, tree_id)) = transaction.last_written_commit() { if last == oid { return Ok(tree_id); @@ -920,7 +920,7 @@ pub(crate) fn filtered_parent_tree_id( fn is_empty_root( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - oid: git2::Oid, + oid: gix_hash::ObjectId, ) -> anyhow::Result { if oid == filter::tree::empty_id() { return Ok(true); @@ -933,13 +933,13 @@ fn is_empty_root( Ok(tree .entries .iter() - .all(|e| e.mode.is_tree() && is_empty_subtree(transaction, odb, objects::git2_oid(e.oid)))) + .all(|e| e.mode.is_tree() && is_empty_subtree(transaction, odb, e.oid.to_owned()))) } fn is_empty_subtree( transaction: &cache::Transaction, odb: &josh_memodb::Odb, - oid: git2::Oid, + oid: gix_hash::ObjectId, ) -> bool { if oid == filter::tree::empty_id() { return true; @@ -952,12 +952,13 @@ fn is_empty_subtree( }; tree.entries .iter() - .all(|e| e.mode.is_tree() && is_empty_subtree(transaction, odb, objects::git2_oid(e.oid))) + .all(|e| e.mode.is_tree() && is_empty_subtree(transaction, odb, e.oid.to_owned())) } #[cfg(test)] mod tests { use super::*; + use std::str::FromStr; // A root is "empty" iff it contains nothing but (recursively) empty trees: the empty tree // itself and nested empty chains qualify; any blob or gitlink anywhere disqualifies. @@ -981,20 +982,22 @@ mod tests { data.extend_from_slice(b"40000 sub"); data.push(0); data.extend_from_slice(empty.as_bytes()); - let chain = repo - .odb() - .unwrap() - .write(git2::ObjectType::Tree, &data) - .unwrap(); + let chain = objects::gix_oid( + repo.odb() + .unwrap() + .write(git2::ObjectType::Tree, &data) + .unwrap(), + ); let mut data = Vec::new(); data.extend_from_slice(b"40000 nested"); data.push(0); data.extend_from_slice(chain.as_bytes()); - let chain2 = repo - .odb() - .unwrap() - .write(git2::ObjectType::Tree, &data) - .unwrap(); + let chain2 = objects::gix_oid( + repo.odb() + .unwrap() + .write(git2::ObjectType::Tree, &data) + .unwrap(), + ); assert!(is_empty_root(&t, odb, chain2).unwrap()); // A blob anywhere makes the root non-empty; so does a gitlink. @@ -1002,15 +1005,18 @@ mod tests { let mut b = git2::build::TreeUpdateBuilder::new(); b.upsert("a/b/file.txt", blob, git2::FileMode::Blob); let base = repo.treebuilder(None).unwrap().write().unwrap(); - let with_blob = b - .create_updated(&repo, &repo.find_tree(base).unwrap()) - .unwrap(); + let with_blob = objects::gix_oid( + b.create_updated(&repo, &repo.find_tree(base).unwrap()) + .unwrap(), + ); assert!(!is_empty_root(&t, odb, with_blob).unwrap()); - let gitlink = git2::Oid::from_str("0123456789012345678901234567890123456789").unwrap(); + let gitlink = + gix_hash::ObjectId::from_str("0123456789012345678901234567890123456789").unwrap(); let mut b = repo.treebuilder(None).unwrap(); - b.insert("sub", gitlink, 0o160000).unwrap(); - let with_gitlink = b.write().unwrap(); + b.insert("sub", objects::git2_oid(&gitlink), 0o160000) + .unwrap(); + let with_gitlink = objects::gix_oid(b.write().unwrap()); assert!(!is_empty_root(&t, odb, with_gitlink).unwrap()); } } diff --git a/josh-core/src/housekeeping.rs b/josh-core/src/housekeeping.rs index d5d22f3b2..027fbc515 100644 --- a/josh-core/src/housekeeping.rs +++ b/josh-core/src/housekeeping.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeSet, HashMap}; use std::sync::LazyLock; use tracing::{Level, info, span}; -pub type KnownViews = HashMap)>; +pub type KnownViews = HashMap)>; static KNOWN_FILTERS: LazyLock> = LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new())); @@ -13,7 +13,7 @@ static KNOWN_FILTERS: LazyLock> = pub fn list_refs( transaction: &cache::Transaction, upstream_repo: &str, -) -> anyhow::Result> { +) -> anyhow::Result> { let mut refs = vec![]; let prefix = ["refs", "josh", "upstream", &to_ns(upstream_repo)] @@ -48,7 +48,12 @@ pub fn remember_filter(upstream_repo: &str, filter_spec: &str) { { let known_f = &mut known_filters .entry(upstream_repo.trim_start_matches('/').to_string()) - .or_insert_with(|| (git2::Oid::ZERO_SHA1, BTreeSet::new())); + .or_insert_with(|| { + ( + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + BTreeSet::new(), + ) + }); known_f.1.insert(filter_spec.to_string()); } @@ -83,7 +88,12 @@ pub fn default_from_to( { let known_f = &mut known_filters .entry(upstream_repo.trim_start_matches('/').to_string()) - .or_insert_with(|| (git2::Oid::ZERO_SHA1, BTreeSet::new())); + .or_insert_with(|| { + ( + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + BTreeSet::new(), + ) + }); known_f.1.insert(filter_spec.to_string()); } @@ -95,7 +105,7 @@ pub fn memorize_from_to( transaction: &cache::Transaction, namespace: &str, upstream_repo: &str, -) -> anyhow::Result<((String, git2::Oid), String)> { +) -> anyhow::Result<((String, gix_hash::ObjectId), String)> { let from = format!("refs/josh/upstream/{}/HEAD", &to_ns(upstream_repo)); let to_ref = format!("refs/{}/HEAD", &namespace); @@ -135,17 +145,19 @@ pub fn discover_filter_candidates(transaction: &cache::Transaction) -> anyhow::R let name = from_ns(&name); - let known_f = &mut known_filters - .entry(name.clone()) - .or_insert_with(|| (git2::Oid::ZERO_SHA1, BTreeSet::new())); + let known_f = &mut known_filters.entry(name.clone()).or_insert_with(|| { + ( + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + BTreeSet::new(), + ) + }); if known_f.0 != target { - // PORT: mirror-resident fetched target, and `peel` tolerates annotated tags -- - // stays on git2 until flag day. + // Fetched targets may be annotated tags. let tree = repo - .find_object(target, None)? + .find_object(objects::git2_oid(&target), None)? .peel(git2::ObjectType::Tree)?; - let hs = find_all_workspaces_and_subdirectories(odb, tree.id())?; + let hs = find_all_workspaces_and_subdirectories(odb, objects::gix_oid(tree.id()))?; known_f.0 = target; for i in hs { known_f.1.insert(i); @@ -161,7 +173,12 @@ pub fn discover_filter_candidates(transaction: &cache::Transaction) -> anyhow::R known_filters .entry(from_ns(&filtered.upstream_repo)) - .or_insert_with(|| (git2::Oid::ZERO_SHA1, BTreeSet::new())) + .or_insert_with(|| { + ( + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + BTreeSet::new(), + ) + }) .1 .insert(from_ns(&filtered.filter_spec)); Ok(()) @@ -172,7 +189,7 @@ pub fn discover_filter_candidates(transaction: &cache::Transaction) -> anyhow::R pub fn find_all_workspaces_and_subdirectories( src: &impl gix_object::Find, - tree: git2::Oid, + tree: gix_hash::ObjectId, ) -> anyhow::Result> { let _trace_s = span!(Level::TRACE, "find_all_workspaces_and_subdirectories"); let mut hs = std::collections::HashSet::new(); @@ -198,7 +215,7 @@ pub fn find_all_workspaces_and_subdirectories( pub fn refresh_known_filters( transaction_mirror: &cache::Transaction, transaction_overlay: &cache::Transaction, -) -> anyhow::Result> { +) -> anyhow::Result> { let known_filters = KNOWN_FILTERS.lock().unwrap(); let mut updated_refs = vec![]; for (upstream_repo, e) in known_filters.iter() { diff --git a/josh-core/src/lib.rs b/josh-core/src/lib.rs index b4e0c8415..d98a08d7a 100644 --- a/josh-core/src/lib.rs +++ b/josh-core/src/lib.rs @@ -26,43 +26,6 @@ pub mod trailers; pub use josh_gix_ext as objects; pub use josh_memodb as memodb; -#[derive( - Clone, Hash, PartialEq, Eq, Copy, PartialOrd, Ord, Debug, serde::Serialize, serde::Deserialize, -)] -#[serde(try_from = "String", into = "String")] -pub struct Oid(git2::Oid); - -impl Default for Oid { - fn default() -> Self { - Oid(git2::Oid::ZERO_SHA1) - } -} - -impl std::convert::TryFrom for Oid { - type Error = anyhow::Error; - fn try_from(s: String) -> anyhow::Result { - Ok(Oid(git2::Oid::from_str(&s)?)) - } -} - -impl From for String { - fn from(val: Oid) -> Self { - val.0.to_string() - } -} - -impl From for git2::Oid { - fn from(val: Oid) -> Self { - val.0 - } -} - -impl From for Oid { - fn from(oid: git2::Oid) -> Self { - Self(oid) - } -} - /// Determine the josh version number with the following precedence: /// /// 1. If in a git checkout, and `git` binary is present, use the @@ -169,8 +132,8 @@ pub fn reset_caches() -> anyhow::Result<()> { pub fn filter_commit( transaction: &cache::Transaction, filterobj: filter::Filter, - oid: git2::Oid, -) -> anyhow::Result { + oid: gix_hash::ObjectId, +) -> anyhow::Result { // A chained filter feeds the previous step's freshly built commit in here, so the peel // has to see the transaction's buffered objects. let original_commit = objects::peel_to_commit(transaction.odb(), oid)?; @@ -191,8 +154,11 @@ pub fn filter_commit( pub fn filter_refs( transaction: &cache::Transaction, filterobj: filter::Filter, - refs: &[(String, git2::Oid)], -) -> (Vec<(String, git2::Oid)>, Vec<(String, anyhow::Error)>) { + refs: &[(String, gix_hash::ObjectId)], +) -> ( + Vec<(String, gix_hash::ObjectId)>, + Vec<(String, anyhow::Error)>, +) { let s = tracing::Span::current(); let _e = s.enter(); let mut updated = vec![]; @@ -211,7 +177,7 @@ pub fn filter_refs( warn = true, from = k.0.as_str(), ); - git2::Oid::ZERO_SHA1 + gix_hash::ObjectId::null(gix_hash::Kind::Sha1) } }; updated.push((k.0.to_string(), oid)); @@ -220,9 +186,9 @@ pub fn filter_refs( (updated, errors) } -pub fn update_refs(transaction: &cache::Transaction, updated: Vec<(String, git2::Oid)>) { +pub fn update_refs(transaction: &cache::Transaction, updated: Vec<(String, gix_hash::ObjectId)>) { for (refn, filtered_commit) in updated.into_iter() { - if filtered_commit.is_zero() { + if filtered_commit.is_null() { continue; } diff --git a/josh-core/src/link.rs b/josh-core/src/link.rs index 3c15a71de..3e8ee6c81 100644 --- a/josh-core/src/link.rs +++ b/josh-core/src/link.rs @@ -1,6 +1,6 @@ pub fn find_link_files( src: &impl gix_object::Find, - tree: git2::Oid, + tree: gix_hash::ObjectId, ) -> anyhow::Result> { use crate::filter; use crate::objects; diff --git a/josh-core/src/submodules.rs b/josh-core/src/submodules.rs index 27fda2c62..95ac45585 100644 --- a/josh-core/src/submodules.rs +++ b/josh-core/src/submodules.rs @@ -104,7 +104,10 @@ pub fn update_gitmodules( .write_to(&mut output) .context("Failed to write gitmodules")?; - String::from_utf8(output).context("Invalid UTF-8 in gitmodules") + let content = String::from_utf8(output).context("Invalid UTF-8 in gitmodules")?; + // gix-config writes the platform's newline, but this ends up in a blob: the same + // filter has to produce the same object on every platform. + Ok(content.replace("\r\n", "\n")) } #[cfg(test)] diff --git a/josh-core/tests/cache_lock.rs b/josh-core/tests/cache_lock.rs index 74d3938eb..1cd56273b 100644 --- a/josh-core/tests/cache_lock.rs +++ b/josh-core/tests/cache_lock.rs @@ -37,7 +37,8 @@ fn sled_lock_follows_transaction_lifetime() { let tree = git .find_tree(git.treebuilder(None).unwrap().write().unwrap()) .unwrap(); - let commit = git.commit(None, &sig, &sig, "c", &tree, &[]).unwrap(); + let commit = + josh_core::objects::gix_oid(git.commit(None, &sig, &sig, "c", &tree, &[]).unwrap()); transaction .insert(filter::parse(":/").unwrap(), commit, commit, true) .unwrap(); diff --git a/josh-filter/Cargo.toml b/josh-filter/Cargo.toml index 7be216014..ca7f0a81b 100644 --- a/josh-filter/Cargo.toml +++ b/josh-filter/Cargo.toml @@ -18,7 +18,6 @@ indoc = "2.0.7" itertools = "0.15.0" anyhow.workspace = true -git2.workspace = true josh-memodb.workspace = true josh-gix-ext.workspace = true gix-object.workspace = true diff --git a/josh-filter/src/filter.rs b/josh-filter/src/filter.rs index 029b20f5e..08163ba6d 100644 --- a/josh-filter/src/filter.rs +++ b/josh-filter/src/filter.rs @@ -56,7 +56,7 @@ impl Default for Filter { impl Filter { /// The content-addressed OID of this filter, computed lazily on first call and cached on the /// node. This is the only place a filter's tree is built (via `build_op`). - pub fn id(&self) -> git2::Oid { + pub fn id(&self) -> gix_hash::ObjectId { *self.0.oid.get_or_init(|| persist::build_node_oid(self.0)) } } @@ -90,7 +90,7 @@ impl Filter { /// relationship to `tip` satisfies `match` (e.g. `AncestorInclusive` is `<=tip`); the first /// matching arm wins and a commit matching none passes through unchanged. Tips are resolved /// oids (`RevMatch::Default` ignores its tip); construct `Op::Rev` directly for lazy refs. - pub fn rev(self, arms: Vec<(RevMatch, git2::Oid, Filter)>) -> Filter { + pub fn rev(self, arms: Vec<(RevMatch, gix_hash::ObjectId, Filter)>) -> Filter { self.chain(to_filter(Op::Rev( arms.into_iter() .map(|(m, tip, then)| (m, LazyRef::Resolved(tip), then)) @@ -196,7 +196,7 @@ impl Filter { pub fn insert_oid( self, path: impl Into, - oid: git2::Oid, + oid: gix_hash::ObjectId, ) -> anyhow::Result { Ok(self.chain(to_filter(Op::Insert(path.into(), InsertContent::Oid(oid))))) } @@ -242,7 +242,7 @@ impl Filter { } /// Chain a squash filter - pub fn squash(self, ids: Option<&[(git2::Oid, Filter)]>) -> Filter { + pub fn squash(self, ids: Option<&[(gix_hash::ObjectId, Filter)]>) -> Filter { self.chain(if let Some(ids) = ids { to_filter(Op::Squash(Some( ids.iter() @@ -256,7 +256,7 @@ impl Filter { /// Chain a downstack filter that rebuilds the stack from `base` to the input commit, /// dropping intermediate commits whose paths are disjoint from the tip's changes. - pub fn downstack(self, base: git2::Oid) -> Filter { + pub fn downstack(self, base: gix_hash::ObjectId) -> Filter { self.chain(to_filter(Op::Downstack(LazyRef::Resolved(base)))) } @@ -373,7 +373,8 @@ pub fn invert(filter: Filter) -> anyhow::Result { /// The sequence_number filter used for tracking commit sequence numbers. A memoized sentinel /// node whose OID is the zero OID, so identity comparison and cache-keying stay correct. pub fn sequence_number() -> Filter { - static F: LazyLock = LazyLock::new(|| persist::sentinel(git2::Oid::ZERO_SHA1)); + static F: LazyLock = + LazyLock::new(|| persist::sentinel(gix_hash::ObjectId::null(gix_hash::Kind::Sha1))); *F } @@ -386,7 +387,7 @@ pub fn reachable_roots() -> Filter { static F: LazyLock = LazyLock::new(|| { let mut bytes = [0u8; 20]; bytes[19] = 1; - persist::sentinel(git2::Oid::from_bytes(&bytes).expect("valid sentinel oid")) + persist::sentinel(gix_hash::ObjectId::from_bytes_or_panic(&bytes)) }); *F } diff --git a/josh-filter/src/flang/parse.rs b/josh-filter/src/flang/parse.rs index d4ccfb3de..8087059e5 100644 --- a/josh-filter/src/flang/parse.rs +++ b/josh-filter/src/flang/parse.rs @@ -183,7 +183,7 @@ fn parse_item(pair: pest::iterators::Pair) -> anyhow::Result { let content_pair = inner.next().unwrap(); let content = match content_pair.as_rule() { Rule::string => InsertContent::Inline(unquote(content_pair.as_str())), - Rule::object_oid => InsertContent::Oid(git2::Oid::from_str(content_pair.as_str())?), + Rule::object_oid => InsertContent::Oid(content_pair.as_str().parse()?), _ => unreachable!(), }; Ok(to_filter(Op::Insert(path, content))) @@ -278,7 +278,9 @@ fn parse_item(pair: pest::iterators::Pair) -> anyhow::Result { let filter = parse(filter_pair.as_str())?; entries.push(( RevMatch::Default, - LazyRef::Resolved(git2::Oid::ZERO_SHA1), + LazyRef::Resolved(gix_hash::ObjectId::null( + gix_hash::Kind::Sha1, + )), filter, )); } diff --git a/josh-filter/src/op.rs b/josh-filter/src/op.rs index c9827418b..ddc09c239 100644 --- a/josh-filter/src/op.rs +++ b/josh-filter/src/op.rs @@ -57,7 +57,7 @@ impl LinkMode { #[derive(Hash, Clone, Debug, PartialEq, PartialOrd, Eq, Ord)] pub enum LazyRef { - Resolved(git2::Oid), + Resolved(gix_hash::ObjectId), Lazy(String), } @@ -67,7 +67,7 @@ pub enum InsertContent { /// An object referenced by OID. The kind (blob or tree) is resolved against a repository /// when the filter is applied or persisted; `persist::as_tree` references the object as a /// tree entry with the matching mode so it is reachable from the filter tree. - Oid(git2::Oid), + Oid(gix_hash::ObjectId), } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -98,7 +98,7 @@ impl LazyRef { return Ok(LazyRef::Lazy(s)); } - if let Ok(oid) = git2::Oid::from_str(&s) { + if let Ok(oid) = s.parse() { Ok(LazyRef::Resolved(oid)) } else { Err(anyhow!("invalid ref: {:?}", s)) diff --git a/josh-filter/src/persist.rs b/josh-filter/src/persist.rs index 6968a92cb..b2dd164f1 100644 --- a/josh-filter/src/persist.rs +++ b/josh-filter/src/persist.rs @@ -12,7 +12,7 @@ use crate::op::{InsertContent, LazyRef, Op, Regex, RevMatch}; /// most once, on demand, via `Filter::id`/`build_node_oid` — never during `to_filter`. pub(crate) struct Node { pub(crate) op: Op, - pub(crate) oid: OnceLock, + pub(crate) oid: OnceLock, } /// Canonicalizes each `Op` to a single interned node by structural equality, so equal `Op`s @@ -121,7 +121,7 @@ impl<'a> InMemoryBuilder<'a> { self.persist_ids .get(&filter) .copied() - .unwrap_or_else(|| gix_hash::ObjectId::from_bytes_or_panic(filter.id().as_bytes())) + .unwrap_or_else(|| filter.id()) } fn write_blob(&mut self, data: &[u8]) -> gix_hash::ObjectId { @@ -403,7 +403,7 @@ impl<'a> InMemoryBuilder<'a> { InsertContent::Oid(oid) => { if let Some(src) = self.src { let kind = src - .try_header(&josh_gix_ext::gix_oid(*oid)) + .try_header(oid.as_ref()) .map_err(|e| anyhow!("insert: object {}: {}", oid, e))? .map(|header| header.kind); let mode = match kind { @@ -426,7 +426,7 @@ impl<'a> InMemoryBuilder<'a> { gix_object::tree::Entry { mode: mode.into(), filename: BString::from("o"), - oid: gix_hash::ObjectId::from_bytes_or_panic(oid.as_bytes()), + oid: *oid, }, ]; self.write_tree(gix_object::Tree { entries }) @@ -594,17 +594,16 @@ pub fn to_filter(op: Op) -> Filter { /// Materialize a node's content OID, building its tree (and, recursively via `build_op`'s child /// `Filter::id` calls, its children's). Called only from `Filter::id`, never on the optimizer /// hot path. -pub(crate) fn build_node_oid(node: &'static Node) -> git2::Oid { +pub(crate) fn build_node_oid(node: &'static Node) -> gix_hash::ObjectId { let mut builder = InMemoryBuilder::new(None); - let tree_id = builder.build_op(&node.op).expect("failed to build op"); - git2::Oid::from_bytes(tree_id.as_bytes()).unwrap() + builder.build_op(&node.op).expect("failed to build op") } /// Construct a sentinel filter: a unique leaked node whose OID is pre-seeded to `oid` (so it /// never goes through `build_op`) and whose op is `Nop`. Sentinels bypass interning, so each /// is a distinct node — pointer-identity equality keeps them distinct from the real `Nop` /// filter and from each other, while `to_op_ref` still yields `Nop`. -pub(crate) fn sentinel(oid: git2::Oid) -> Filter { +pub(crate) fn sentinel(oid: gix_hash::ObjectId) -> Filter { let node: &'static Node = Box::leak(Box::new(Node { op: Op::Nop, oid: OnceLock::new(), @@ -637,10 +636,10 @@ impl<'a> InMemoryBuilder<'a> { let oid = if dirty { self.build_op(op)? } else { - if !out.exists(&josh_gix_ext::gix_oid(filter.id())) { + if !out.exists(filter.id().as_ref()) { self.build_op(op)?; } - gix_hash::ObjectId::from_bytes_or_panic(filter.id().as_bytes()) + filter.id() }; self.persist_ids.insert(filter, oid); Ok(oid) @@ -650,12 +649,11 @@ impl<'a> InMemoryBuilder<'a> { pub fn as_tree( out: &(impl gix_object::FindHeader + gix_object::Exists + gix_object::Write), filter: Filter, -) -> anyhow::Result { +) -> anyhow::Result { let filter = crate::opt::optimize(filter); let mut builder = InMemoryBuilder::new(Some(out)); let root_oid = builder.build_persist(out, filter)?; - let root_oid = git2::Oid::from_bytes(root_oid.as_bytes())?; builder.staging.flush(out)?; @@ -755,8 +753,11 @@ impl Blob { } } -pub fn from_tree(src: &impl gix_object::Find, tree_oid: git2::Oid) -> anyhow::Result { - Ok(to_filter(from_tree2(src, josh_gix_ext::gix_oid(tree_oid))?)) +pub fn from_tree( + src: &impl gix_object::Find, + tree_oid: gix_hash::ObjectId, +) -> anyhow::Result { + Ok(to_filter(from_tree2(src, tree_oid)?)) } fn from_tree2(src: &impl gix_object::Find, tree_oid: gix_hash::ObjectId) -> anyhow::Result { @@ -918,7 +919,7 @@ fn from_tree2(src: &impl gix_object::Find, tree_oid: gix_hash::ObjectId) -> anyh if let Some(obj_entry) = inner.get_name("o") { return Ok(Op::Insert( std::path::PathBuf::from(path), - InsertContent::Oid(josh_gix_ext::git2_oid(&obj_entry.id())), + InsertContent::Oid(obj_entry.id()), )); } let kind_blob = Blob::read( @@ -1182,7 +1183,10 @@ fn from_tree2(src: &impl gix_object::Find, tree_oid: gix_hash::ObjectId) -> anyh // Parse match operator from key let (match_op, lazy_ref) = if key == "_" { // Default filter - no SHA needed - (RevMatch::Default, LazyRef::Resolved(git2::Oid::ZERO_SHA1)) + ( + RevMatch::Default, + LazyRef::Resolved(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)), + ) } else if let Some(ref_str) = key.strip_prefix("<=") { (RevMatch::AncestorInclusive, LazyRef::parse(ref_str)?) } else if let Some(ref_str) = key.strip_prefix('<') { diff --git a/josh-git-serde/Cargo.toml b/josh-git-serde/Cargo.toml index 34b74f79c..be6ccdf50 100644 --- a/josh-git-serde/Cargo.toml +++ b/josh-git-serde/Cargo.toml @@ -10,13 +10,11 @@ edition = "2024" [dependencies] anyhow.workspace = true -git2.workspace = true gix-object.workspace = true +gix-hash.workspace = true percent-encoding = "2.3.2" serde.workspace = true thiserror.workspace = true -josh-gix-ext.workspace = true - [dev-dependencies] -gix-hash.workspace = true +josh-gix-ext.workspace = true diff --git a/josh-git-serde/src/store.rs b/josh-git-serde/src/store.rs index 22c60026b..376b6577e 100644 --- a/josh-git-serde/src/store.rs +++ b/josh-git-serde/src/store.rs @@ -16,9 +16,14 @@ use crate::value::GitValue; /// Tree entry names must be single path components: non-empty, no '/' or /// NUL, not "." or "..". Keys produced by the serializer always satisfy /// this; the check exists because [`GitValue`] is public. -pub fn to_tree_oid(out: &impl gix_object::Write, value: &GitValue) -> anyhow::Result { +pub fn to_tree_oid( + out: &impl gix_object::Write, + value: &GitValue, +) -> anyhow::Result { match value { - GitValue::Blob(data) => josh_gix_ext::write_blob(out, data), + GitValue::Blob(data) => out + .write_buf(gix_object::Kind::Blob, data) + .map_err(|e| anyhow::anyhow!("to_tree_oid: {e}")), GitValue::Tree(entries) => { let mut tree_entries = Vec::with_capacity(entries.len()); for (name, child) in entries { @@ -30,11 +35,14 @@ pub fn to_tree_oid(out: &impl gix_object::Write, value: &GitValue) -> anyhow::Re GitValue::Blob(_) => gix_object::tree::EntryKind::Blob.into(), }, filename: name.as_str().into(), - oid: josh_gix_ext::gix_oid(oid), + oid, }); } tree_entries.sort(); - josh_gix_ext::write_tree_now(out, tree_entries) + out.write(&gix_object::Tree { + entries: tree_entries, + }) + .map_err(|e| anyhow::anyhow!("to_tree_oid: {e}")) } } } @@ -50,7 +58,10 @@ pub fn to_tree_oid(out: &impl gix_object::Write, value: &GitValue) -> anyhow::Re /// Nesting deeper than [`MAX_TREE_DEPTH`] is rejected rather than risking a /// stack overflow on adversarial objects, as are duplicate entry names /// (possible only in hand-crafted trees). -pub fn from_tree_oid(source: &impl gix_object::Find, root: git2::Oid) -> anyhow::Result { +pub fn from_tree_oid( + source: &impl gix_object::Find, + root: gix_hash::ObjectId, +) -> anyhow::Result { read_tree_oid(source, root, 0) } @@ -65,7 +76,7 @@ fn validate_entry_name(name: &str) -> anyhow::Result<()> { fn read_tree_oid( source: &impl gix_object::Find, - root: git2::Oid, + root: gix_hash::ObjectId, depth: usize, ) -> anyhow::Result { if depth > MAX_TREE_DEPTH { @@ -76,7 +87,7 @@ fn read_tree_oid( let mut buf = Vec::new(); let data = source - .try_find(&josh_gix_ext::gix_oid(root), &mut buf) + .try_find(&root, &mut buf) .map_err(|e| anyhow::anyhow!("from_tree_oid: {e}"))? .ok_or_else(|| anyhow::anyhow!("from_tree_oid: object {root} not found"))?; let object_hash = data.object_hash; @@ -98,7 +109,7 @@ fn read_tree_oid( entry.mode )); } - let child = read_tree_oid(source, josh_gix_ext::git2_oid(entry.oid), depth + 1)?; + let child = read_tree_oid(source, entry.oid.to_owned(), depth + 1)?; if entries.insert(name.clone(), Box::new(child)).is_some() { return Err(anyhow::anyhow!( "from_tree_oid: duplicate entry name {name:?}" diff --git a/josh-git-serde/tests/store.rs b/josh-git-serde/tests/store.rs index f9e8b04d4..6e17e63e4 100644 --- a/josh-git-serde/tests/store.rs +++ b/josh-git-serde/tests/store.rs @@ -100,7 +100,11 @@ fn blob_root_roundtrips() { let stage = Stage::new(); let value = GitValue::blob_from_str("lone blob"); let root = to_tree_oid(&stage, &value).unwrap(); - assert_eq!(root, josh_gix_ext::hash_blob(b"lone blob")); + assert_eq!( + root, + gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, b"lone blob") + .unwrap() + ); let back = from_tree_oid(&*stage.0.borrow(), root).unwrap(); assert_eq!(value, back); } @@ -127,10 +131,7 @@ fn written_tree_is_in_canonical_order() { let staging = stage.0.borrow(); let mut buf = Vec::new(); let (kind, object_hash) = { - let data = staging - .try_find(&josh_gix_ext::gix_oid(root), &mut buf) - .unwrap() - .unwrap(); + let data = staging.try_find(&root, &mut buf).unwrap().unwrap(); (data.kind, data.object_hash) }; assert_eq!(kind, gix_object::Kind::Tree); @@ -161,7 +162,7 @@ fn written_tree_is_in_canonical_order() { let name: &[u8] = entry.filename.as_ref(); if name == b"foo" { assert_eq!(entry.mode.kind(), gix_object::tree::EntryKind::Tree); - let sub = from_tree_oid(&*staging, josh_gix_ext::git2_oid(entry.oid)).unwrap(); + let sub = from_tree_oid(&*staging, entry.oid.to_owned()).unwrap(); assert_eq!( sub, GitValue::Tree(BTreeMap::from([( @@ -184,7 +185,7 @@ fn commit_object_is_rejected() { let mut staging = StagingOdb::new(); // Contents are irrelevant: the kind alone must trigger the error. let commit = staging.write_raw(gix_object::Kind::Commit, b"tree".to_vec()); - let err = from_tree_oid(&staging, josh_gix_ext::git2_oid(&commit)).unwrap_err(); + let err = from_tree_oid(&staging, commit).unwrap_err(); assert!( err.to_string().contains("not a tree or a blob"), "unexpected error: {err}" @@ -194,7 +195,7 @@ fn commit_object_is_rejected() { #[test] fn missing_object_is_an_error() { let staging = StagingOdb::new(); - let missing = git2::Oid::from_str("0123456789012345678901234567890123456789").unwrap(); + let missing = gix_hash::ObjectId::from_bytes_or_panic(&[1; 20]); let err = from_tree_oid(&staging, missing).unwrap_err(); assert!( err.to_string().contains("not found"), @@ -222,7 +223,7 @@ fn executable_blob_entry_is_rejected() { gix_object::Kind::Tree, raw_tree(&[("100755", "exec", &blob)]), ); - let err = from_tree_oid(&staging, josh_gix_ext::git2_oid(&tree)).unwrap_err(); + let err = from_tree_oid(&staging, tree).unwrap_err(); assert!( err.to_string().contains("unsupported mode"), "unexpected error: {err}" @@ -238,7 +239,7 @@ fn duplicate_entry_names_are_rejected() { gix_object::Kind::Tree, raw_tree(&[("100644", "dup", &a), ("100644", "dup", &b)]), ); - let err = from_tree_oid(&staging, josh_gix_ext::git2_oid(&tree)).unwrap_err(); + let err = from_tree_oid(&staging, tree).unwrap_err(); assert!( err.to_string().contains("duplicate entry name"), "unexpected error: {err}" diff --git a/josh-gix-ext/src/graph.rs b/josh-gix-ext/src/graph.rs index f02d818c8..4d0438ea7 100644 --- a/josh-gix-ext/src/graph.rs +++ b/josh-gix-ext/src/graph.rs @@ -4,15 +4,13 @@ //! Both queries are merge-base computations: `gix_revision` walks the commits it reads from //! `objects`, keeping the answers consistent with what the caller can see. -use crate::{git2_oid, gix_oid}; - /// A commit the walk cannot read is indistinguishable from unrelated history, so the inputs /// are checked up front: asking about an object that is not there is a caller bug, not an /// answer of "no". -fn ensure_commit(objects: &impl gix_object::Find, oid: git2::Oid) -> anyhow::Result<()> { +fn ensure_commit(objects: &impl gix_object::Find, oid: gix_hash::ObjectId) -> anyhow::Result<()> { let mut buf = Vec::new(); let data = objects - .try_find(&gix_oid(oid), &mut buf) + .try_find(&oid, &mut buf) .map_err(|e| anyhow::anyhow!("{oid}: {e}"))? .ok_or_else(|| anyhow::anyhow!("object {oid} not found"))?; if data.kind != gix_object::Kind::Commit { @@ -29,18 +27,18 @@ fn ensure_commit(objects: &impl gix_object::Find, oid: git2::Oid) -> anyhow::Res /// descend from itself. pub fn is_descendant_of( objects: &impl gix_object::Find, - commit: git2::Oid, - ancestor: git2::Oid, + commit: gix_hash::ObjectId, + ancestor: gix_hash::ObjectId, ) -> anyhow::Result { if commit == ancestor { return Ok(false); } ensure_commit(objects, commit)?; ensure_commit(objects, ancestor)?; - let ancestor = gix_oid(ancestor); + let ancestor = ancestor; let mut graph = gix_revision::Graph::new(objects, None); // Reachable is exactly "is one of the best common ancestors of the two". - let bases = gix_revision::merge_base(gix_oid(commit), &[ancestor], &mut graph) + let bases = gix_revision::merge_base(commit, &[ancestor], &mut graph) .map_err(|e| anyhow::anyhow!("is_descendant_of: {e}"))?; Ok(bases.is_some_and(|bases| bases.iter().any(|base| *base == ancestor))) } @@ -49,15 +47,15 @@ pub fn is_descendant_of( /// several equally good candidates the choice among them is arbitrary. pub fn merge_base( objects: &impl gix_object::Find, - a: git2::Oid, - b: git2::Oid, -) -> anyhow::Result { + a: gix_hash::ObjectId, + b: gix_hash::ObjectId, +) -> anyhow::Result { ensure_commit(objects, a)?; ensure_commit(objects, b)?; let mut graph = gix_revision::Graph::new(objects, None); - gix_revision::merge_base(gix_oid(a), &[gix_oid(b)], &mut graph) + gix_revision::merge_base(a, &[b], &mut graph) .map_err(|e| anyhow::anyhow!("merge_base: {e}"))? - .map(|bases| git2_oid(bases.first())) + .map(|bases| bases.first().to_owned()) .ok_or_else(|| anyhow::anyhow!("{a} and {b} share no history")) } @@ -65,24 +63,25 @@ pub fn merge_base( /// share history. pub fn merge_base_octopus( objects: &impl gix_object::Find, - commits: &[git2::Oid], -) -> anyhow::Result> { + commits: &[gix_hash::ObjectId], +) -> anyhow::Result> { let (first, rest) = commits .split_first() .ok_or_else(|| anyhow::anyhow!("merge_base_octopus: no commits"))?; for commit in commits { ensure_commit(objects, *commit)?; } - let rest: Vec<_> = rest.iter().map(|c| gix_oid(*c)).collect(); + let rest = rest.to_vec(); let mut graph = gix_revision::Graph::new(objects, None); - let base = gix_revision::merge_base::octopus(gix_oid(*first), &rest, &mut graph) + let base = gix_revision::merge_base::octopus(*first, &rest, &mut graph) .map_err(|e| anyhow::anyhow!("merge_base_octopus: {e}"))?; - Ok(base.map(|id| git2_oid(&id))) + Ok(base) } #[cfg(test)] mod tests { use super::*; + use std::str::FromStr; struct TestRepo { _dir: tempfile::TempDir, @@ -98,7 +97,7 @@ mod tests { /// Commits are dated by their index so the merge-base walk sees a plausible history; /// equal timestamps would still be correct, just slower to settle. - fn commit(&self, seconds: i64, parents: &[git2::Oid]) -> git2::Oid { + fn commit(&self, seconds: i64, parents: &[gix_hash::ObjectId]) -> gix_hash::ObjectId { let sig = git2::Signature::new("Test", "test@example.com", &git2::Time::new(seconds, 0)) .unwrap(); @@ -106,12 +105,14 @@ mod tests { let tree = self.repo.find_tree(tree_id).unwrap(); let parents: Vec<_> = parents .iter() - .map(|&p| self.repo.find_commit(p).unwrap()) + .map(|p| self.repo.find_commit(crate::git2_oid(p)).unwrap()) .collect(); let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); - self.repo - .commit(None, &sig, &sig, "c", &tree, &parent_refs) - .unwrap() + crate::gix_oid( + self.repo + .commit(None, &sig, &sig, "c", &tree, &parent_refs) + .unwrap(), + ) } } @@ -130,64 +131,6 @@ mod tests { assert!(!is_descendant_of(&objects, tip, tip).unwrap()); } - #[test] - fn merge_descends_from_both_sides_but_the_sides_do_not() { - let t = TestRepo::new(); - let root = t.commit(1000, &[]); - let left = t.commit(1001, &[root]); - let right = t.commit(1002, &[root]); - let merge = t.commit(1003, &[left, right]); - let odb = t.repo.odb().unwrap(); - let objects = crate::Git2Odb(&odb); - - assert!(is_descendant_of(&objects, merge, left).unwrap()); - assert!(is_descendant_of(&objects, merge, right).unwrap()); - assert!(!is_descendant_of(&objects, left, right).unwrap()); - assert!(!is_descendant_of(&objects, right, left).unwrap()); - } - - #[test] - fn unrelated_histories_never_descend() { - let t = TestRepo::new(); - let a = t.commit(1000, &[]); - let b = t.commit(1001, &[]); - let odb = t.repo.odb().unwrap(); - let objects = crate::Git2Odb(&odb); - - assert!(!is_descendant_of(&objects, a, b).unwrap()); - assert!(!is_descendant_of(&objects, b, a).unwrap()); - assert_eq!(merge_base_octopus(&objects, &[a, b]).unwrap(), None); - } - - #[test] - fn octopus_base_is_shared_by_every_input() { - let t = TestRepo::new(); - let root = t.commit(1000, &[]); - let a = t.commit(1001, &[root]); - let b = t.commit(1002, &[root]); - let c = t.commit(1003, &[a]); - let odb = t.repo.odb().unwrap(); - let objects = crate::Git2Odb(&odb); - - assert_eq!( - merge_base_octopus(&objects, &[c, b]).unwrap(), - Some(root), - "a fork joins at the root" - ); - assert_eq!( - merge_base_octopus(&objects, &[c, a]).unwrap(), - Some(a), - "an ancestor is its own best base" - ); - assert_eq!(merge_base_octopus(&objects, &[c]).unwrap(), Some(c)); - - let unrelated = t.commit(1004, &[]); - assert_eq!( - merge_base_octopus(&objects, &[c, b, unrelated]).unwrap(), - None - ); - } - #[test] fn merge_base_is_the_join_of_two_lineages() { let t = TestRepo::new(); @@ -211,9 +154,12 @@ mod tests { let root = t.commit(1000, &[]); let odb = t.repo.odb().unwrap(); let objects = crate::Git2Odb(&odb); - let missing = git2::Oid::from_str("1234567890123456789012345678901234567890").unwrap(); + let missing = + gix_hash::ObjectId::from_str("1234567890123456789012345678901234567890").unwrap(); assert!(is_descendant_of(&objects, missing, root).is_err()); assert!(merge_base_octopus(&objects, &[root, missing]).is_err()); + + assert!(merge_base_octopus(&objects, &[]).is_err()); } } diff --git a/josh-gix-ext/src/lib.rs b/josh-gix-ext/src/lib.rs index dbdda209d..1a48a0408 100644 --- a/josh-gix-ext/src/lib.rs +++ b/josh-gix-ext/src/lib.rs @@ -1,28 +1,24 @@ -//! In-memory object staging over a single object database. -//! -//! This is the transition vehicle for the incremental git2 -> gix port: all gix-object compute -//! (tree construction, commit parsing and serialization, hashing) works against this adapter, -//! which stages written objects in memory and reads through to the one repository object -//! database. At no point does a second repository handle perform I/O -- the lesson from the -//! reverted side-by-side gitoxide integration (cd6dc206) is that gix is used for pure in-memory -//! compute while a single ODB owns all I/O. -//! -//! Objects are staged as raw `(kind, bytes)` pairs keyed by their content hash, computed with -//! [`gix_object::compute_hash`] -- no repository access, no zlib, no filesystem. [`flush`] batch -//! writes the staged objects to the repository ODB at an explicit boundary, skipping objects that -//! already exist (on some platforms `exists()` is cheaper in terms of I/O than `write()`, because -//! `write()` updates the file access time in the loose object backend). -//! -//! The adapter implements [`gix_object::Find`] (and friends), so gix readers -- `TreeRef` -//! parsing, `CommitRefIter`, the tree editor, the topo walk -- see staged-but-unflushed objects -//! and disk objects through one interface. -//! -//! [`flush`]: StagingOdb::flush +//! Git object helpers over trait-based object stores. use std::collections::HashMap; use gix_object::WriteTo; +/// The raw bytes of a path component, for matching against git tree entry names. +#[cfg(unix)] +pub fn component_bytes(c: &std::ffi::OsStr) -> &[u8] { + std::os::unix::ffi::OsStrExt::as_bytes(c) +} + +/// The raw bytes of a path component, for matching against git tree entry names: their UTF-8 +/// encoding. Panics on a component that is not valid Unicode, which cannot name a tree entry. +#[cfg(windows)] +pub fn component_bytes(c: &std::ffi::OsStr) -> &[u8] { + c.to_str() + .expect("path component is not valid Unicode") + .as_bytes() +} + pub mod graph; pub mod merge; pub mod revwalk; @@ -31,8 +27,7 @@ pub use graph::{is_descendant_of, merge_base, merge_base_octopus}; pub use merge::{merge_commits, merge_trees}; pub use revwalk::{RangeWalk, RevWalk}; -/// Map the kind of a raw object between the two libraries. Infallible: both enums cover exactly -/// the four git object kinds. +/// Convert a gitoxide object kind to libgit2. pub fn git2_kind(kind: gix_object::Kind) -> git2::ObjectType { match kind { gix_object::Kind::Tree => git2::ObjectType::Tree, @@ -42,7 +37,7 @@ pub fn git2_kind(kind: gix_object::Kind) -> git2::ObjectType { } } -/// See [`git2_kind`]. Fails only for `Any`/`Ref`, which are not object kinds. +/// Convert a libgit2 object kind when it represents an object. pub fn gix_kind(kind: git2::ObjectType) -> Option { match kind { git2::ObjectType::Tree => Some(gix_object::Kind::Tree), @@ -53,31 +48,29 @@ pub fn gix_kind(kind: git2::ObjectType) -> Option { } } -/// Zero-cost oid conversion: both libraries use the same 20-byte binary representation. +/// Convert a SHA-1 object ID to gitoxide. pub fn gix_oid(oid: git2::Oid) -> gix_hash::ObjectId { gix_hash::ObjectId::from_bytes_or_panic(oid.as_bytes()) } -/// See [`gix_oid`]. +/// Convert a SHA-1 object ID to libgit2. pub fn git2_oid(oid: &gix_hash::oid) -> git2::Oid { git2::Oid::from_bytes(oid.as_bytes()).expect("oid sizes match") } -/// Hash `data` as a blob without writing it anywhere. -pub fn hash_blob(data: &[u8]) -> git2::Oid { - git2_oid( - &gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, data) - .expect("failed to compute hash"), - ) +/// Hash a blob without writing it. +pub fn hash_blob(data: &[u8]) -> gix_hash::ObjectId { + gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, data) + .expect("failed to compute hash") } /// Follow `oid` to the commit it names, unwrapping annotated tags on the way. Errors when the /// object is missing or resolves to something that is not a commit. pub fn peel_to_commit( src: &(impl gix_object::Find + ?Sized), - oid: git2::Oid, -) -> anyhow::Result { - let mut current = gix_oid(oid); + oid: gix_hash::ObjectId, +) -> anyhow::Result { + let mut current = oid; let mut buffer = Vec::new(); loop { let data = src @@ -85,7 +78,7 @@ pub fn peel_to_commit( .map_err(|e| anyhow::anyhow!("peel {}: {}", current, e))? .ok_or_else(|| anyhow::anyhow!("object {} not found", current))?; match data.kind { - gix_object::Kind::Commit => return Ok(git2_oid(¤t)), + gix_object::Kind::Commit => return Ok(current), gix_object::Kind::Tag => { current = gix_object::TagRefIter::from_bytes(&buffer, gix_hash::Kind::Sha1) .target_id()?; @@ -105,11 +98,11 @@ pub fn peel_to_commit( /// Errors when the object is missing or is not a tree. pub fn read_tree_entries( src: &(impl gix_object::Find + ?Sized), - oid: git2::Oid, + oid: gix_hash::ObjectId, ) -> anyhow::Result> { let mut buffer = Vec::new(); let data = src - .try_find(&gix_oid(oid), &mut buffer) + .try_find(&oid, &mut buffer) .map_err(|e| anyhow::anyhow!("read tree {}: {}", oid, e))? .ok_or_else(|| anyhow::anyhow!("object {} not found", oid))?; if data.kind != gix_object::Kind::Tree { @@ -126,7 +119,7 @@ pub fn read_tree_entries( /// entry on the way; the final entry may be of any kind. pub fn path_entry( src: &(impl gix_object::Find + ?Sized), - oid: git2::Oid, + oid: gix_hash::ObjectId, path: &std::path::Path, ) -> anyhow::Result> { let mut current = oid; @@ -134,7 +127,7 @@ pub fn path_entry( while let Some(component) = components.next() { let mut buffer = Vec::new(); let Some(data) = src - .try_find(&gix_oid(current), &mut buffer) + .try_find(¤t, &mut buffer) .map_err(|e| anyhow::anyhow!("read tree {}: {}", current, e))? else { return Ok(None); @@ -143,14 +136,14 @@ pub fn path_entry( return Ok(None); } let parsed = gix_object::TreeRef::from_bytes(&buffer, gix_hash::Kind::Sha1)?; - let name = std::os::unix::ffi::OsStrExt::as_bytes(component.as_os_str()); + let name = component_bytes(component.as_os_str()); let Some(entry) = parsed.entries.iter().find(|e| e.filename == name) else { return Ok(None); }; if components.peek().is_none() { return Ok(Some((*entry).into())); } - current = git2_oid(entry.oid); + current = entry.oid.to_owned(); } Ok(None) } @@ -158,9 +151,9 @@ pub fn path_entry( /// The text of the blob `oid`, or `""` when it is missing, is not a blob, holds a NUL byte or /// is not valid UTF-8 -- the tolerance the display and script paths want, where a file that /// cannot be shown is the same as a file that is not there. -pub fn blob_text(src: &(impl gix_object::Find + ?Sized), oid: git2::Oid) -> String { +pub fn blob_text(src: &(impl gix_object::Find + ?Sized), oid: gix_hash::ObjectId) -> String { let mut buffer = Vec::new(); - let Ok(Some(data)) = src.try_find(&gix_oid(oid), &mut buffer) else { + let Ok(Some(data)) = src.try_find(&oid, &mut buffer) else { return String::new(); }; if data.kind != gix_object::Kind::Blob || buffer.contains(&0) { @@ -179,7 +172,7 @@ pub fn blob_text(src: &(impl gix_object::Find + ?Sized), oid: git2::Oid) -> Stri /// the merge commits it creates. pub fn walk_tree_preorder( src: &impl gix_object::Find, - root: git2::Oid, + root: gix_hash::ObjectId, cb: &mut dyn FnMut(&str, &gix_object::tree::EntryRef<'_>) -> anyhow::Result<()>, ) -> anyhow::Result<()> { let mut path = String::new(); @@ -188,13 +181,13 @@ pub fn walk_tree_preorder( fn walk_tree_preorder_inner( src: &impl gix_object::Find, - tree: git2::Oid, + tree: gix_hash::ObjectId, path: &mut String, cb: &mut dyn FnMut(&str, &gix_object::tree::EntryRef<'_>) -> anyhow::Result<()>, ) -> anyhow::Result<()> { let mut buf = Vec::new(); let data = src - .try_find(&gix_oid(tree), &mut buf) + .try_find(&tree, &mut buf) .map_err(|e| anyhow::anyhow!("walk_tree_preorder: {e}"))? .ok_or_else(|| anyhow::anyhow!("object {} not found", tree))?; if data.kind != gix_object::Kind::Tree { @@ -215,7 +208,7 @@ fn walk_tree_preorder_inner( path.push('/'); } path.push_str(name); - walk_tree_preorder_inner(src, git2_oid(entry.oid), path, cb)?; + walk_tree_preorder_inner(src, entry.oid.to_owned(), path, cb)?; path.truncate(base); } } @@ -233,7 +226,7 @@ fn walk_tree_preorder_inner( pub fn write_tree_now( out: &impl gix_object::Write, entries: Vec, -) -> anyhow::Result { +) -> anyhow::Result { // Exact-fit upper bound: mode (<= 6 octal digits) + space + name + NUL + 20 oid bytes. let mut buffer = Vec::with_capacity( entries @@ -258,15 +251,15 @@ pub fn write_tree_now( let id = out .write_buf(gix_object::Kind::Tree, &buffer) .map_err(|e| anyhow::anyhow!("write_tree_now: {e}"))?; - Ok(git2_oid(&id)) + Ok(id) } /// Write `data` as a blob to `out`. -pub fn write_blob(out: &impl gix_object::Write, data: &[u8]) -> anyhow::Result { +pub fn write_blob(out: &impl gix_object::Write, data: &[u8]) -> anyhow::Result { let id = out .write_buf(gix_object::Kind::Blob, data) .map_err(|e| anyhow::anyhow!("write_blob: {e}"))?; - Ok(git2_oid(&id)) + Ok(id) } /// Serialize a new commit and write it to `out`. Signatures carry the seconds and UTC @@ -274,15 +267,15 @@ pub fn write_blob(out: &impl gix_object::Write, data: &[u8]) -> anyhow::Result, committer: &git2::Signature<'_>, message: &str, -) -> anyhow::Result { +) -> anyhow::Result { let commit = gix_object::Commit { - tree: gix_oid(tree), - parents: parents.iter().map(|p| gix_oid(*p)).collect(), + tree, + parents: parents.to_vec().into(), author: gix_signature(author)?, committer: gix_signature(committer)?, encoding: None, @@ -294,7 +287,7 @@ pub fn write_commit( let id = out .write_buf(gix_object::Kind::Commit, &buffer) .map_err(|e| anyhow::anyhow!("write_commit: {e}"))?; - Ok(git2_oid(&id)) + Ok(id) } /// Serialize a new commit whose author and committer are taken from `base`, and write it to @@ -302,14 +295,14 @@ pub fn write_commit( pub fn write_commit_with_signatures_of( out: &impl gix_object::Write, base: &CommitData, - tree: git2::Oid, - parents: &[git2::Oid], + tree: gix_hash::ObjectId, + parents: &[gix_hash::ObjectId], message: &str, -) -> anyhow::Result { +) -> anyhow::Result { let parsed = base.parsed()?; let commit = gix_object::Commit { - tree: gix_oid(tree), - parents: parents.iter().map(|p| gix_oid(*p)).collect(), + tree, + parents: parents.to_vec().into(), author: parsed.author()?.into(), committer: parsed.committer()?.into(), encoding: None, @@ -321,7 +314,7 @@ pub fn write_commit_with_signatures_of( let id = out .write_buf(gix_object::Kind::Commit, &buffer) .map_err(|e| anyhow::anyhow!("write_commit: {e}"))?; - Ok(git2_oid(&id)) + Ok(id) } /// The gix spelling of a git2 signature: name and email verbatim, and the timestamp as @@ -343,17 +336,20 @@ pub fn gix_signature(sig: &git2::Signature<'_>) -> anyhow::Result` later without any signature change. #[derive(Clone, Debug)] pub struct CommitData { - id: git2::Oid, + id: gix_hash::ObjectId, bytes: Vec, } impl CommitData { /// Errors if the object is missing or not a commit. `src` is the transaction's facade in /// practice, so unflushed in-memory commits resolve. - pub fn read(src: &impl gix_object::Find, oid: git2::Oid) -> anyhow::Result { + pub fn read( + src: &impl gix_object::Find, + oid: gix_hash::ObjectId, + ) -> anyhow::Result { let mut bytes = Vec::new(); let data = src - .try_find(&gix_oid(oid), &mut bytes) + .try_find(&oid, &mut bytes) .map_err(|e| anyhow::anyhow!("CommitData::read: {e}"))? .ok_or_else(|| anyhow::anyhow!("object {} not found", oid))?; if data.kind != gix_object::Kind::Commit { @@ -366,7 +362,7 @@ impl CommitData { Ok(CommitData { id: oid, bytes }) } - pub fn id(&self) -> git2::Oid { + pub fn id(&self) -> gix_hash::ObjectId { self.id } @@ -384,10 +380,10 @@ impl CommitData { )?) } - pub fn tree_id(&self) -> anyhow::Result { + pub fn tree_id(&self) -> anyhow::Result { let id = gix_object::CommitRefIter::from_bytes(&self.bytes, gix_hash::Kind::Sha1).tree_id()?; - Ok(git2_oid(&id)) + Ok(id) } /// The stored message verbatim, by way of a full commit parse -- an unparseable commit @@ -413,13 +409,11 @@ impl CommitData { /// Binary parent ids read from the parsed commit header id array via /// `CommitRefIter::parent_ids`; never does odb lookups. - pub fn parent_ids(&self) -> impl Iterator + '_ { - gix_object::CommitRefIter::from_bytes(&self.bytes, gix_hash::Kind::Sha1) - .parent_ids() - .map(|p| git2_oid(&p)) + pub fn parent_ids(&self) -> impl Iterator + '_ { + gix_object::CommitRefIter::from_bytes(&self.bytes, gix_hash::Kind::Sha1).parent_ids() } - pub fn first_parent_id(&self) -> Option { + pub fn first_parent_id(&self) -> Option { self.parent_ids().next() } @@ -646,33 +640,20 @@ impl gix_object::Write for Git2Odb<'_> { mod tests { use super::*; - #[test] - fn hash_blob_matches_git2() { - for data in [&b""[..], b"x", b"1:{\"a\":1}\n"] { - assert_eq!( - super::hash_blob(data), - git2::Oid::hash_object(git2::ObjectType::Blob, data).unwrap() - ); - } - assert_eq!( - super::hash_blob(b"").to_string(), - "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" - ); - } - - /// Write a commit with `message` verbatim through the raw odb, so non-UTF-8 messages can - /// be expressed too. - fn commit_with_message(repo: &git2::Repository, message: &[u8]) -> git2::Oid { + /// Write raw commit bytes, including non-UTF-8 messages. + fn commit_with_message(repo: &git2::Repository, message: &[u8]) -> gix_hash::ObjectId { let tree = repo.treebuilder(None).unwrap().write().unwrap(); let mut data = Vec::new(); data.extend_from_slice(format!("tree {}\n", tree).as_bytes()); data.extend_from_slice(b"author t 0 +0000\n"); data.extend_from_slice(b"committer t 0 +0000\n\n"); data.extend_from_slice(message); - repo.odb() - .unwrap() - .write(git2::ObjectType::Commit, &data) - .unwrap() + gix_oid( + repo.odb() + .unwrap() + .write(git2::ObjectType::Commit, &data) + .unwrap(), + ) } /// Commits written here must be byte-identical to the ones libgit2 writes for the same @@ -684,17 +665,17 @@ mod tests { let repo = git2::Repository::init_bare(dir.path()).unwrap(); let odb = repo.odb().unwrap(); - let tree = repo.treebuilder(None).unwrap().write().unwrap(); + let tree = gix_oid(repo.treebuilder(None).unwrap().write().unwrap()); let blob = repo.blob(b"x").unwrap(); let mut b = repo.treebuilder(None).unwrap(); b.insert("f", blob, 0o100644).unwrap(); - let tree2 = b.write().unwrap(); + let tree2 = gix_oid(b.write().unwrap()); let sig = |name: &str, email: &str, secs: i64, offset: i32| { git2::Signature::new(name, email, &git2::Time::new(secs, offset)).unwrap() }; - let cases: Vec<(git2::Signature, git2::Signature, &str, git2::Oid)> = vec![ + let cases: Vec<(git2::Signature, git2::Signature, &str, gix_hash::ObjectId)> = vec![ ( sig("A", "a@e", 0, 0), sig("A", "a@e", 0, 0), @@ -719,30 +700,33 @@ mod tests { for (author, committer, message, tree) in &cases { for parents in [vec![], vec![0usize], vec![0, 1]] { // Build the parent commits with git2 so both writers see identical inputs. - let parent_ids: Vec = parents + let parent_ids: Vec = parents .iter() .map(|i| { - let t = repo.find_tree(*tree).unwrap(); - repo.commit(None, author, committer, &format!("parent {i}"), &t, &[]) - .unwrap() + let t = repo.find_tree(git2_oid(tree)).unwrap(); + gix_oid( + repo.commit(None, author, committer, &format!("parent {i}"), &t, &[]) + .unwrap(), + ) }) .collect(); let parent_commits: Vec = parent_ids .iter() - .map(|id| repo.find_commit(*id).unwrap()) + .map(|id| repo.find_commit(git2_oid(id)).unwrap()) .collect(); let parent_refs: Vec<&git2::Commit> = parent_commits.iter().collect(); - let want = repo - .commit( + let want = gix_oid( + repo.commit( None, author, committer, message, - &repo.find_tree(*tree).unwrap(), + &repo.find_tree(git2_oid(tree)).unwrap(), &parent_refs, ) - .unwrap(); + .unwrap(), + ); let got = write_commit( &Git2Odb(&odb), *tree, @@ -804,9 +788,9 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let repo = git2::Repository::init_bare(dir.path()).unwrap(); let odb = repo.odb().unwrap(); - let blob = repo.blob(b"x").unwrap(); + let blob = gix_oid(repo.blob(b"x").unwrap()); - let write_tree = |entries: &[(&str, &str, git2::Oid)]| -> git2::Oid { + let write_tree = |entries: &[(&str, &str, gix_hash::ObjectId)]| -> gix_hash::ObjectId { let mut data = Vec::new(); for (mode, name, oid) in entries { data.extend_from_slice(mode.as_bytes()); @@ -815,10 +799,12 @@ mod tests { data.push(0); data.extend_from_slice(oid.as_bytes()); } - repo.odb() - .unwrap() - .write(git2::ObjectType::Tree, &data) - .unwrap() + gix_oid( + repo.odb() + .unwrap() + .write(git2::ObjectType::Tree, &data) + .unwrap(), + ) }; let deep = write_tree(&[("100644", "leaf.txt", blob)]); @@ -862,21 +848,22 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let repo = git2::Repository::init_bare(dir.path()).unwrap(); let odb = repo.odb().unwrap(); - let blob = repo.blob(b"x").unwrap(); + let blob = gix_oid(repo.blob(b"x").unwrap()); let mut inner = repo.treebuilder(None).unwrap(); - inner.insert("f.txt", blob, 0o100644).unwrap(); + inner.insert("f.txt", git2_oid(&blob), 0o100644).unwrap(); let inner = inner.write().unwrap(); let mut data = Vec::new(); data.extend_from_slice(b"40000 bad\xff"); data.push(0); data.extend_from_slice(inner.as_bytes()); - let root = repo - .odb() - .unwrap() - .write(git2::ObjectType::Tree, &data) - .unwrap(); + let root = gix_oid( + repo.odb() + .unwrap() + .write(git2::ObjectType::Tree, &data) + .unwrap(), + ); assert!(walk_tree_preorder(&Git2Odb(&odb), root, &mut |_, _| Ok(())).is_err()); } diff --git a/josh-gix-ext/src/merge.rs b/josh-gix-ext/src/merge.rs index 27d4c2eca..874535ea1 100644 --- a/josh-gix-ext/src/merge.rs +++ b/josh-gix-ext/src/merge.rs @@ -4,8 +4,6 @@ //! josh merges bare trees: there is no worktree to read from, no attributes to consult and no //! external merge drivers to run, so the platforms below are the empty configuration of each. -use crate::{git2_oid, gix_oid}; - /// Which side wins a conflicting hunk. #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum Favor { @@ -107,8 +105,8 @@ fn options(favor: Option) -> gix_merge::tree::Options { fn write_result( objects: &impl gix_object::Write, mut outcome: gix_merge::tree::Outcome<'_>, - labels: (git2::Oid, git2::Oid), -) -> anyhow::Result { + labels: (gix_hash::ObjectId, gix_hash::ObjectId), +) -> anyhow::Result { let unresolved = gix_merge::tree::TreatAsUnresolved::default(); if outcome.has_unresolved_conflicts(unresolved) { let paths: Vec<_> = outcome @@ -128,21 +126,21 @@ fn write_result( .tree .write(|tree| gix_object::Write::write(objects, tree)) .map_err(|e| anyhow::anyhow!("writing merge result: {e}"))?; - Ok(git2_oid(&id)) + Ok(id) } /// Merge `ours` and `theirs` against their common ancestor `base`, all trees. pub fn merge_trees( objects: &(impl gix_object::FindObjectOrHeader + gix_object::Write), - base: git2::Oid, - ours: git2::Oid, - theirs: git2::Oid, -) -> anyhow::Result { + base: gix_hash::ObjectId, + ours: gix_hash::ObjectId, + theirs: gix_hash::ObjectId, +) -> anyhow::Result { let mut platforms = platforms(); let outcome = gix_merge::tree( - &gix_oid(base), - &gix_oid(ours), - &gix_oid(theirs), + &base, + &ours, + &theirs, gix_merge::blob::builtin_driver::text::Labels::default(), objects, |buf| gix_object::Write::write_buf(objects, gix_object::Kind::Blob, buf), @@ -159,15 +157,15 @@ pub fn merge_trees( /// conflicts no side preference can resolve. pub fn merge_commits( objects: &(impl gix_object::FindObjectOrHeader + gix_object::Write), - ours: git2::Oid, - theirs: git2::Oid, + ours: gix_hash::ObjectId, + theirs: gix_hash::ObjectId, favor: Option, -) -> anyhow::Result { +) -> anyhow::Result { let mut platforms = platforms(); let mut graph = gix_revwalk::Graph::new(objects, None); let outcome = gix_merge::commit( - gix_oid(ours), - gix_oid(theirs), + ours, + theirs, gix_merge::blob::builtin_driver::text::Labels::default(), &mut graph, &mut platforms.diff, @@ -199,33 +197,39 @@ mod tests { TestRepo { _dir: dir, repo } } - fn tree(&self, files: &[(&str, &str)]) -> git2::Oid { + fn tree(&self, files: &[(&str, &str)]) -> gix_hash::ObjectId { let mut builder = self.repo.treebuilder(None).unwrap(); for (name, content) in files { let blob = self.repo.blob(content.as_bytes()).unwrap(); builder.insert(name, blob, 0o100644).unwrap(); } - builder.write().unwrap() + crate::gix_oid(builder.write().unwrap()) } - fn commit(&self, tree: git2::Oid, parents: &[git2::Oid]) -> git2::Oid { + fn commit( + &self, + tree: gix_hash::ObjectId, + parents: &[gix_hash::ObjectId], + ) -> gix_hash::ObjectId { let sig = git2::Signature::new("Test", "test@example.com", &git2::Time::new(1000, 0)) .unwrap(); - let tree = self.repo.find_tree(tree).unwrap(); + let tree = self.repo.find_tree(crate::git2_oid(&tree)).unwrap(); let parents: Vec<_> = parents .iter() - .map(|&p| self.repo.find_commit(p).unwrap()) + .map(|p| self.repo.find_commit(crate::git2_oid(p)).unwrap()) .collect(); let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); - self.repo - .commit(None, &sig, &sig, "c", &tree, &parent_refs) - .unwrap() + crate::gix_oid( + self.repo + .commit(None, &sig, &sig, "c", &tree, &parent_refs) + .unwrap(), + ) } - fn file(&self, tree: git2::Oid, name: &str) -> String { + fn file(&self, tree: gix_hash::ObjectId, name: &str) -> String { let entry = self .repo - .find_tree(tree) + .find_tree(crate::git2_oid(&tree)) .unwrap() .get_name(name) .unwrap_or_else(|| panic!("{name} not in tree")) @@ -234,24 +238,6 @@ mod tests { } } - /// Edits on different lines of one file merge into a single file holding both, and each - /// side's other changes carry over. - #[test] - fn non_overlapping_edits_merge_cleanly() { - let t = TestRepo::new(); - let base = t.tree(&[("a", "1\n2\n3\n"), ("b", "keep\n")]); - let ours = t.tree(&[("a", "one\n2\n3\n"), ("b", "keep\n"), ("new", "ours\n")]); - let theirs = t.tree(&[("a", "1\n2\nthree\n"), ("b", "keep\n")]); - let odb = t.repo.odb().unwrap(); - let objects = crate::Git2Odb(&odb); - - let merged = merge_trees(&objects, base, ours, theirs).unwrap(); - - assert_eq!(t.file(merged, "a"), "one\n2\nthree\n"); - assert_eq!(t.file(merged, "b"), "keep\n"); - assert_eq!(t.file(merged, "new"), "ours\n"); - } - #[test] fn overlapping_edits_conflict() { let t = TestRepo::new(); @@ -283,8 +269,6 @@ mod tests { assert_eq!(t.file(merged, "a"), "theirs\n"); } - /// The conflicts josh reports are the ones no side preference can decide, which is what - /// makes a favored merge fail at all. #[test] fn a_favor_cannot_decide_delete_against_modify() { let t = TestRepo::new(); @@ -299,19 +283,4 @@ mod tests { .to_string(); assert!(err.contains("conflicts in a"), "{err}"); } - - #[test] - fn commits_merge_across_their_common_ancestor() { - let t = TestRepo::new(); - let base = t.commit(t.tree(&[("a", "1\n"), ("b", "1\n")]), &[]); - let ours = t.commit(t.tree(&[("a", "2\n"), ("b", "1\n")]), &[base]); - let ours = t.commit(t.tree(&[("a", "3\n"), ("b", "1\n")]), &[ours]); - let theirs = t.commit(t.tree(&[("a", "1\n"), ("b", "2\n")]), &[base]); - let odb = t.repo.odb().unwrap(); - let objects = crate::Git2Odb(&odb); - - let merged = merge_commits(&objects, ours, theirs, Some(Favor::Ours)).unwrap(); - assert_eq!(t.file(merged, "a"), "3\n"); - assert_eq!(t.file(merged, "b"), "2\n"); - } } diff --git a/josh-gix-ext/src/revwalk.rs b/josh-gix-ext/src/revwalk.rs index 066888de2..9566b45e5 100644 --- a/josh-gix-ext/src/revwalk.rs +++ b/josh-gix-ext/src/revwalk.rs @@ -26,9 +26,7 @@ use std::collections::HashMap; use std::ops::ControlFlow; -use crate::git2_oid; - -type Visit<'v> = &'v mut dyn FnMut(git2::Oid) -> anyhow::Result>; +type Visit<'v> = &'v mut dyn FnMut(gix_hash::ObjectId) -> anyhow::Result>; /// Read a commit's parent ids: one raw object read, one `CommitRefIter` pass /// stopped at the first non-tree/parent header. A missing or non-commit @@ -36,11 +34,11 @@ type Visit<'v> = &'v mut dyn FnMut(git2::Oid) -> anyhow::Result> /// message) are never read or validated. fn read_parent_oids( src: &impl gix_object::Find, - oid: git2::Oid, + oid: gix_hash::ObjectId, buf: &mut Vec, -) -> anyhow::Result> { +) -> anyhow::Result> { let data = src - .try_find(&crate::gix_oid(oid), buf) + .try_find(&oid, buf) .map_err(|e| anyhow::anyhow!("read_parent_oids: {e}"))? .ok_or_else(|| anyhow::anyhow!("object {} not found", oid))?; if data.kind != gix_object::Kind::Commit { @@ -51,7 +49,7 @@ fn read_parent_oids( use gix_object::commit::ref_iter::Token; match token? { Token::Tree { .. } => {} - Token::Parent { id } => parents.push(git2_oid(&id)), + Token::Parent { id } => parents.push(id), _ => break, } } @@ -59,9 +57,9 @@ fn read_parent_oids( } /// Verify `oid` names a commit without decompressing it. -fn ensure_commit(src: &impl gix_object::FindHeader, oid: git2::Oid) -> anyhow::Result<()> { +fn ensure_commit(src: &impl gix_object::FindHeader, oid: gix_hash::ObjectId) -> anyhow::Result<()> { let header = src - .try_header(&crate::gix_oid(oid)) + .try_header(&oid) .map_err(|e| anyhow::anyhow!("ensure_commit: {e}"))? .ok_or_else(|| anyhow::anyhow!("object {} not found", oid))?; if header.kind != gix_object::Kind::Commit { @@ -72,7 +70,7 @@ fn ensure_commit(src: &impl gix_object::FindHeader, oid: git2::Oid) -> anyhow::R /// Per-commit state of a [`RevWalk`]. struct Node { - oid: git2::Oid, + oid: gix_hash::ObjectId, /// Parents as node indices, filled in on first visit. parents: Vec, parsed: bool, @@ -107,7 +105,7 @@ struct Node { pub struct RevWalk<'a, S> { odb: &'a S, nodes: Vec, - by_oid: HashMap, + by_oid: HashMap, /// Pushed tips in push order; the DFS explores them in this order. tips: Vec, first_parent: bool, @@ -129,7 +127,7 @@ impl<'a, S: gix_object::Find + gix_object::FindHeader> RevWalk<'a, S> { /// Mark a commit to start traversal from. A missing oid or a non-commit /// object errors immediately. - pub fn push(&mut self, tip: git2::Oid) -> anyhow::Result<()> { + pub fn push(&mut self, tip: gix_hash::ObjectId) -> anyhow::Result<()> { ensure_commit(self.odb, tip)?; let tip = self.intern(tip); self.tips.push(tip); @@ -145,8 +143,8 @@ impl<'a, S: gix_object::Find + gix_object::FindHeader> RevWalk<'a, S> { /// parents); iterate the result back to front for parents-first order. pub fn into_topo_vec( mut self, - mut prune: impl FnMut(git2::Oid) -> bool, - ) -> anyhow::Result> { + mut prune: impl FnMut(gix_hash::ObjectId) -> bool, + ) -> anyhow::Result> { let post = self.dfs(&mut prune, &mut None)?; Ok(post.into_iter().rev().map(|i| self.nodes[i].oid).collect()) } @@ -156,16 +154,16 @@ impl<'a, S: gix_object::Find + gix_object::FindHeader> RevWalk<'a, S> { /// [`ControlFlow::Break`] to abort the walk. pub fn discover( mut self, - mut visit: impl FnMut(git2::Oid) -> anyhow::Result>, + mut visit: impl FnMut(gix_hash::ObjectId) -> anyhow::Result>, ) -> anyhow::Result<()> { - let mut keep_all = |_: git2::Oid| false; + let mut keep_all = |_: gix_hash::ObjectId| false; let visit: Visit<'_> = &mut visit; self.dfs(&mut keep_all, &mut Some(visit))?; Ok(()) } /// The node index for `oid`, creating blank unparsed state on first sight. - fn intern(&mut self, oid: git2::Oid) -> usize { + fn intern(&mut self, oid: gix_hash::ObjectId) -> usize { if let Some(&i) = self.by_oid.get(&oid) { return i; } @@ -200,7 +198,7 @@ impl<'a, S: gix_object::Find + gix_object::FindHeader> RevWalk<'a, S> { /// `Break` aborts the walk with the partial result. fn dfs( &mut self, - prune: &mut dyn FnMut(git2::Oid) -> bool, + prune: &mut dyn FnMut(gix_hash::ObjectId) -> bool, visit: &mut Option>, ) -> anyhow::Result> { enum Frame { @@ -248,7 +246,7 @@ const FAST_FORWARD_PROBE_LIMIT: usize = 1000; /// Per-commit state of a [`RangeWalk`]. struct RangeNode { - oid: git2::Oid, + oid: gix_hash::ObjectId, /// Parents as node indices, filled in on first visit. parents: Vec, parsed: bool, @@ -292,9 +290,9 @@ struct RangeNode { /// because the callers consume the whole yield set parents-first. pub struct RangeWalk<'a, S> { odb: &'a S, - sequence_numbers: Box anyhow::Result + 'a>, + sequence_numbers: Box anyhow::Result + 'a>, nodes: Vec, - by_oid: HashMap, + by_oid: HashMap, /// Reused per-commit read buffer, so a walk does one allocation, not one per commit. scratch: Vec, } @@ -302,7 +300,7 @@ pub struct RangeWalk<'a, S> { impl<'a, S: gix_object::Find + gix_object::FindHeader> RangeWalk<'a, S> { pub fn new( odb: &'a S, - sequence_numbers: impl Fn(git2::Oid) -> anyhow::Result + 'a, + sequence_numbers: impl Fn(gix_hash::ObjectId) -> anyhow::Result + 'a, ) -> Self { RangeWalk { odb, @@ -320,9 +318,9 @@ impl<'a, S: gix_object::Find + gix_object::FindHeader> RangeWalk<'a, S> { /// actually reaches them. pub fn into_topo_vec( mut self, - tip: git2::Oid, - base: git2::Oid, - ) -> anyhow::Result> { + tip: gix_hash::ObjectId, + base: gix_hash::ObjectId, + ) -> anyhow::Result> { ensure_commit(self.odb, tip)?; ensure_commit(self.odb, base)?; if tip == base { @@ -338,7 +336,7 @@ impl<'a, S: gix_object::Find + gix_object::FindHeader> RangeWalk<'a, S> { } /// The node index for `oid`, creating blank unparsed state on first sight. - fn intern(&mut self, oid: git2::Oid) -> usize { + fn intern(&mut self, oid: gix_hash::ObjectId) -> usize { if let Some(&i) = self.by_oid.get(&oid) { return i; } @@ -377,7 +375,7 @@ impl<'a, S: gix_object::Find + gix_object::FindHeader> RangeWalk<'a, S> { &mut self, tip: usize, base: usize, - ) -> anyhow::Result>> { + ) -> anyhow::Result>> { let mut out = Vec::new(); let mut c = tip; for _ in 0..FAST_FORWARD_PROBE_LIMIT { @@ -399,7 +397,7 @@ impl<'a, S: gix_object::Find + gix_object::FindHeader> RangeWalk<'a, S> { /// The ranked strategy (see the type docs): a max-heap frontier keyed by /// sequence number, ties resolved by insertion order (equal numbers are /// never ancestor-related, so any deterministic order is correct). - fn ranked_walk(&mut self, tip: usize, base: usize) -> anyhow::Result> { + fn ranked_walk(&mut self, tip: usize, base: usize) -> anyhow::Result> { let seq_of = std::mem::replace(&mut self.sequence_numbers, Box::new(|_| unreachable!())); let mut heap: std::collections::BinaryHeap<(u64, std::cmp::Reverse, usize)> = std::collections::BinaryHeap::new(); @@ -467,6 +465,7 @@ impl<'a, S: gix_object::Find + gix_object::FindHeader> RangeWalk<'a, S> { mod tests { use super::*; use std::collections::HashSet; + use std::str::FromStr; struct TestRepo { _dir: tempfile::TempDir, @@ -480,24 +479,26 @@ mod tests { TestRepo { _dir: dir, repo } } - fn commit(&self, msg: &str, parents: &[git2::Oid]) -> git2::Oid { + fn commit(&self, msg: &str, parents: &[gix_hash::ObjectId]) -> gix_hash::ObjectId { let sig = git2::Signature::new("Test", "test@example.com", &git2::Time::new(1000, 0)) .unwrap(); let tree_id = self.repo.treebuilder(None).unwrap().write().unwrap(); let tree = self.repo.find_tree(tree_id).unwrap(); let parents: Vec<_> = parents .iter() - .map(|&p| self.repo.find_commit(p).unwrap()) + .map(|p| self.repo.find_commit(crate::git2_oid(p)).unwrap()) .collect(); let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); - self.repo - .commit(None, &sig, &sig, msg, &tree, &parent_refs) - .unwrap() + crate::gix_oid( + self.repo + .commit(None, &sig, &sig, msg, &tree, &parent_refs) + .unwrap(), + ) } /// Write raw commit bytes so parents don't have to exist in the odb /// (and may repeat -- git2's commit builder can produce neither). - fn raw_commit(&self, msg: &str, parents: &[git2::Oid]) -> git2::Oid { + fn raw_commit(&self, msg: &str, parents: &[gix_hash::ObjectId]) -> gix_hash::ObjectId { let tree_id = self.repo.treebuilder(None).unwrap().write().unwrap(); let mut buf = format!("tree {}\n", tree_id); for p in parents { @@ -507,11 +508,13 @@ mod tests { "author Test 1000 +0000\n\ committer Test 1000 +0000\n\n{msg}\n" )); - self.repo - .odb() - .unwrap() - .write(git2::ObjectType::Commit, buf.as_bytes()) - .unwrap() + crate::gix_oid( + self.repo + .odb() + .unwrap() + .write(git2::ObjectType::Commit, buf.as_bytes()) + .unwrap(), + ) } } @@ -520,13 +523,17 @@ mod tests { /// Errors on missing ancestors, like the real lookup. fn exact_seq( repo: &git2::Repository, - memo: &std::cell::RefCell>, - oid: git2::Oid, + memo: &std::cell::RefCell>, + oid: gix_hash::ObjectId, ) -> anyhow::Result { if let Some(&s) = memo.borrow().get(&oid) { return Ok(s); } - let parents: Vec = repo.find_commit(oid)?.parent_ids().collect(); + let parents: Vec = repo + .find_commit(crate::git2_oid(&oid))? + .parent_ids() + .map(crate::gix_oid) + .collect(); let mut max = 0; for p in parents { max = max.max(exact_seq(repo, memo, p)?); @@ -542,24 +549,27 @@ mod tests { /// no code with the walkers. fn reference_set( repo: &git2::Repository, - tips: &[git2::Oid], - base: Option, - pruned: &HashSet, + tips: &[gix_hash::ObjectId], + base: Option, + pruned: &HashSet, first_parent: bool, - ) -> HashSet { - let parents_of = |oid: git2::Oid| -> Vec { - let c = repo.find_commit(oid).unwrap(); - c.parent_ids().collect() + ) -> HashSet { + let parents_of = |oid: gix_hash::ObjectId| -> Vec { + repo.find_commit(crate::git2_oid(&oid)) + .unwrap() + .parent_ids() + .map(crate::gix_oid) + .collect() }; - let mut hidden: HashSet = HashSet::new(); - let mut stack: Vec = base.into_iter().collect(); + let mut hidden: HashSet = HashSet::new(); + let mut stack: Vec = base.into_iter().collect(); while let Some(h) = stack.pop() { if hidden.insert(h) { stack.extend(parents_of(h)); } } let mut out = HashSet::new(); - let mut stack: Vec = tips.to_vec(); + let mut stack: Vec = tips.to_vec(); while let Some(c) = stack.pop() { if out.contains(&c) || hidden.contains(&c) || pruned.contains(&c) { continue; @@ -576,10 +586,10 @@ mod tests { fn rev_walk( repo: &git2::Repository, - tips: &[git2::Oid], - pruned: &HashSet, + tips: &[gix_hash::ObjectId], + pruned: &HashSet, first_parent: bool, - ) -> anyhow::Result> { + ) -> anyhow::Result> { let odb = repo.odb().unwrap(); let odb = crate::Git2Odb(&odb); let mut walk = RevWalk::new(&odb); @@ -594,9 +604,9 @@ mod tests { fn range_walk( repo: &git2::Repository, - tip: git2::Oid, - base: git2::Oid, - ) -> anyhow::Result> { + tip: gix_hash::ObjectId, + base: gix_hash::ObjectId, + ) -> anyhow::Result> { let memo = std::cell::RefCell::new(HashMap::new()); let odb = repo.odb().unwrap(); let odb = crate::Git2Odb(&odb); @@ -606,12 +616,19 @@ mod tests { /// Check a yielded order for topological validity over the followed /// edges: every yielded commit precedes its yielded parents. - fn assert_topo(repo: &git2::Repository, got: &[git2::Oid], first_parent: bool, mode: &str) { - let pos: HashMap = got.iter().enumerate().map(|(i, &o)| (o, i)).collect(); + fn assert_topo( + repo: &git2::Repository, + got: &[gix_hash::ObjectId], + first_parent: bool, + mode: &str, + ) { + let pos: HashMap = + got.iter().enumerate().map(|(i, &o)| (o, i)).collect(); assert_eq!(pos.len(), got.len(), "duplicate yields: {mode}"); for &c in got { - let commit = repo.find_commit(c).unwrap(); - let mut parents: Vec = commit.parent_ids().collect(); + let commit = repo.find_commit(crate::git2_oid(&c)).unwrap(); + let mut parents: Vec = + commit.parent_ids().map(crate::gix_oid).collect(); if first_parent { parents.truncate(1); } @@ -628,15 +645,15 @@ mod tests { /// equality with `git2::Revwalk`. fn assert_rev_walk( repo: &git2::Repository, - tips: &[git2::Oid], - pruned: &HashSet, + tips: &[gix_hash::ObjectId], + pruned: &HashSet, label: &str, ) { for first_parent in [false, true] { let mode = format!("{label} first_parent={first_parent}"); let got = rev_walk(repo, tips, pruned, first_parent).unwrap(); let want = reference_set(repo, tips, None, pruned, first_parent); - let got_set: HashSet = got.iter().copied().collect(); + let got_set: HashSet = got.iter().copied().collect(); assert_eq!(got_set, want, "yield set mismatch: {mode}"); assert_topo(repo, &got, first_parent, &mode); let again = rev_walk(repo, tips, pruned, first_parent).unwrap(); @@ -648,10 +665,11 @@ mod tests { if first_parent { g2.simplify_first_parent().unwrap(); } - for &t in tips { - g2.push(t).unwrap(); + for t in tips { + g2.push(crate::git2_oid(t)).unwrap(); } - let theirs: HashSet = g2.map(|r| r.unwrap()).collect(); + let theirs: HashSet = + g2.map(|r| crate::gix_oid(r.unwrap())).collect(); assert_eq!(got_set, theirs, "git2 set mismatch: {mode}"); } } @@ -659,17 +677,22 @@ mod tests { /// Assert [`RangeWalk`] against the reference model: exact yield set, /// valid topological order, run-to-run determinism. - fn assert_range_walk(repo: &git2::Repository, tip: git2::Oid, base: git2::Oid, label: &str) { + fn assert_range_walk( + repo: &git2::Repository, + tip: gix_hash::ObjectId, + base: gix_hash::ObjectId, + label: &str, + ) { let got = range_walk(repo, tip, base).unwrap(); let want = reference_set(repo, &[tip], Some(base), &HashSet::new(), false); - let got_set: HashSet = got.iter().copied().collect(); + let got_set: HashSet = got.iter().copied().collect(); assert_eq!(got_set, want, "yield set mismatch: {label}"); assert_topo(repo, &got, false, label); let again = range_walk(repo, tip, base).unwrap(); assert_eq!(got, again, "non-deterministic order: {label}"); } - fn no_prune() -> HashSet { + fn no_prune() -> HashSet { HashSet::new() } @@ -689,10 +712,13 @@ mod tests { } // Forward order is tip-first down the chain. let got = range_walk(&t.repo, tip, all[5]).unwrap(); - let expect: Vec = all[6..].iter().rev().copied().collect(); + let expect: Vec = all[6..].iter().rev().copied().collect(); assert_eq!(got, expect); // An empty range yields nothing. - assert_eq!(range_walk(&t.repo, tip, tip).unwrap(), vec![]); + assert_eq!( + range_walk(&t.repo, tip, tip).unwrap(), + Vec::::new() + ); } #[test] @@ -756,7 +782,7 @@ mod tests { } let tip = t.commit("tip", &[base, b]); assert_range_walk(&t.repo, tip, base, "merged-side-branch"); - let got: HashSet = range_walk(&t.repo, tip, base) + let got: HashSet = range_walk(&t.repo, tip, base) .unwrap() .into_iter() .collect(); @@ -899,7 +925,7 @@ mod tests { false }) .unwrap(); - let unique: HashSet = calls.iter().copied().collect(); + let unique: HashSet = calls.iter().copied().collect(); assert_eq!(calls.len(), unique.len(), "prune consulted once per commit"); } @@ -938,18 +964,20 @@ mod tests { fn input_error_cases() { let t = TestRepo::new(); let a = t.commit("a", &[]); - let missing = git2::Oid::from_str("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").unwrap(); - let blob = t - .repo - .odb() - .unwrap() - .write(git2::ObjectType::Blob, b"x") - .unwrap(); - let tree = t.repo.treebuilder(None).unwrap().write().unwrap(); + let missing = + gix_hash::ObjectId::from_str("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").unwrap(); + let blob = crate::gix_oid( + t.repo + .odb() + .unwrap() + .write(git2::ObjectType::Blob, b"x") + .unwrap(), + ); + let tree = crate::gix_oid(t.repo.treebuilder(None).unwrap().write().unwrap()); let sig = git2::Signature::new("Test", "test@example.com", &git2::Time::new(1000, 0)).unwrap(); - let obj = t.repo.find_object(a, None).unwrap(); - let tag = t.repo.tag("v1", &obj, &sig, "annotated", false).unwrap(); + let obj = t.repo.find_object(crate::git2_oid(&a), None).unwrap(); + let tag = crate::gix_oid(t.repo.tag("v1", &obj, &sig, "annotated", false).unwrap()); let odb = t.repo.odb().unwrap(); let odb = crate::Git2Odb(&odb); @@ -967,7 +995,8 @@ mod tests { #[test] fn missing_ancestors_error_only_when_visited() { let t = TestRepo::new(); - let missing = git2::Oid::from_str("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").unwrap(); + let missing = + gix_hash::ObjectId::from_str("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").unwrap(); let dangling = t.raw_commit("dangling", &[missing]); // A pushed commit with a missing parent errors during the walk. @@ -1000,12 +1029,13 @@ mod tests { let t = TestRepo::new(); let tree_id = t.repo.treebuilder(None).unwrap().write().unwrap(); let odb = t.repo.odb().unwrap(); - let trunc = odb - .write( + let trunc = crate::gix_oid( + odb.write( git2::ObjectType::Commit, format!("tree {}\n", tree_id).as_bytes(), ) - .unwrap(); + .unwrap(), + ); let odb = crate::Git2Odb(&odb); let tip = t.raw_commit("tip", &[trunc]); let mut w = RevWalk::new(&odb); @@ -1095,9 +1125,9 @@ mod tests { let mut rng = Rng(seed); let t = TestRepo::new(); let n = 20 + rng.below(30); - let mut commits: Vec = Vec::new(); + let mut commits: Vec = Vec::new(); for i in 0..n { - let parents: Vec = if commits.is_empty() || rng.below(12) == 0 { + let parents: Vec = if commits.is_empty() || rng.below(12) == 0 { vec![] } else { let k = 1 + rng.below(3.min(commits.len())); @@ -1116,7 +1146,7 @@ mod tests { for _ in 0..1 + rng.below(3) { tips.push(commits[rng.below(commits.len())]); } - let pruned: HashSet = commits + let pruned: HashSet = commits .iter() .copied() .filter(|_| rng.below(6) == 0) diff --git a/josh-graphql/Cargo.toml b/josh-graphql/Cargo.toml index 26ec9607a..6552476c5 100644 --- a/josh-graphql/Cargo.toml +++ b/josh-graphql/Cargo.toml @@ -15,6 +15,7 @@ strfmt = "0.2.5" anyhow.workspace = true juniper.workspace = true git2.workspace = true +gix-hash.workspace = true regex.workspace = true serde_json.workspace = true serde_yaml.workspace = true diff --git a/josh-graphql/src/graphql.rs b/josh-graphql/src/graphql.rs index dc7805ac4..68a6d1316 100644 --- a/josh-graphql/src/graphql.rs +++ b/josh-graphql/src/graphql.rs @@ -7,16 +7,17 @@ use josh_core::objects; use josh_core::objects::CommitData; use josh_core::{cache, filter, history}; use juniper::{EmptyMutation, EmptySubscription, FieldResult, graphql_object}; +use std::str::FromStr; pub struct Revision { filter: filter::Filter, - commit_id: git2::Oid, + commit_id: gix_hash::ObjectId, } fn find_paths( transaction: &cache::Transaction, odb: &josh_core::memodb::Odb, - tree: git2::Oid, + tree: gix_hash::ObjectId, at: Option, depth: Option, kind: git2::ObjectType, @@ -28,7 +29,7 @@ fn find_paths( if !entry.mode.is_tree() { return Err(anyhow!("not a directory: {}", at)); } - objects::git2_oid(&entry.oid) + entry.oid.to_owned() } _ => tree, }; @@ -45,7 +46,7 @@ fn find_paths( fn collect_paths( transaction: &cache::Transaction, odb: &josh_core::memodb::Odb, - tree: git2::Oid, + tree: gix_hash::ObjectId, prefix: &std::path::Path, level: i32, depth: Option, @@ -72,7 +73,7 @@ fn collect_paths( collect_paths( transaction, odb, - objects::git2_oid(&entry.oid), + entry.oid.to_owned(), &path, level + 1, depth, @@ -88,7 +89,7 @@ fn collect_paths( fn filtered_commit( transaction: &cache::Transaction, filter: filter::Filter, - commit_id: git2::Oid, + commit_id: gix_hash::ObjectId, ) -> anyhow::Result { let filtered = filter::apply_to_commit(filter, commit_id, transaction)?; CommitData::read(transaction.odb(), filtered) @@ -229,7 +230,7 @@ impl Revision { id, false, ) - .unwrap_or_else(|_| git2::Oid::ZERO_SHA1), + .unwrap_or_else(|_| gix_hash::ObjectId::null(gix_hash::Kind::Sha1)), }) .collect(); @@ -266,7 +267,7 @@ impl Revision { let orig = history::find_original(&transaction, self.filter, contained_in, ids[i], true)?; - if orig != git2::Oid::ZERO_SHA1 { + if orig != gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { ids[i] = orig; contained_in = josh_core::git::read_parent_ids(transaction.odb(), ids[i])? .into_iter() @@ -318,7 +319,10 @@ impl Revision { let odb = transaction.odb(); let (parent_id, parent_tree_id) = match filter_commit.first_parent_id() { Some(parent) => (parent, josh_core::git::read_tree_id(odb, parent)?), - None => (git2::Oid::ZERO_SHA1, git2::Oid::ZERO_SHA1), + None => ( + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + gix_hash::ObjectId::null(gix_hash::Kind::Sha1), + ), }; let filter_tree_id = filter_commit.tree_id()?; @@ -500,9 +504,9 @@ impl Warning { #[derive(Clone)] pub struct Path { path: std::path::PathBuf, - commit_id: git2::Oid, + commit_id: gix_hash::ObjectId, filter: filter::Filter, - tree: git2::Oid, + tree: gix_hash::ObjectId, } #[derive(Clone)] @@ -539,7 +543,7 @@ impl SearchResult { pub fn linecount( transaction: &cache::Transaction, odb: &josh_core::memodb::Odb, - id: git2::Oid, + id: gix_hash::ObjectId, ) -> usize { if let Some(blob) = tree::blob_bytes(odb, id) { return blob.iter().filter(|x| **x == b'\n').count() + if blob.is_empty() { 0 } else { 1 }; @@ -548,7 +552,7 @@ pub fn linecount( if let Ok(reader) = tree::read_tree(transaction, odb, id) { return reader .entries() - .map(|e| linecount(transaction, odb, objects::git2_oid(&e.oid))) + .map(|e| linecount(transaction, odb, e.oid.to_owned())) .sum(); } 0 @@ -556,7 +560,7 @@ pub fn linecount( struct Markers { path: std::path::PathBuf, - commit_id: git2::Oid, + commit_id: gix_hash::ObjectId, filter: filter::Filter, topic: String, } @@ -590,7 +594,7 @@ impl Markers { let prev = match tree::get_path_entry(&transaction, odb, tree, &path)? { Some(entry) => { - let blob = tree::blob_bytes(odb, objects::git2_oid(&entry.oid)) + let blob = tree::blob_bytes(odb, entry.oid.to_owned()) .ok_or_else(|| anyhow!("not a blob: {}", entry.oid))?; std::str::from_utf8(&blob)?.to_owned() } @@ -605,8 +609,8 @@ impl Markers { Document { id: s .next() - .and_then(|x| git2::Oid::from_str(x).ok()) - .unwrap_or(git2::Oid::ZERO_SHA1), + .and_then(|x| gix_hash::ObjectId::from_str(x).ok()) + .unwrap_or(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)), value: s .next() .and_then(|x| serde_json::from_str::(x).ok()) @@ -637,7 +641,7 @@ impl Markers { .ok() .flatten() .filter(|entry| entry.mode.is_tree()) - .map(|entry| objects::git2_oid(&entry.oid)) + .map(|entry| entry.oid.to_owned()) .unwrap_or_else(filter::tree::empty_id); let mtree = if self.filter.is_nop() { @@ -651,7 +655,7 @@ impl Markers { )? }; if let Ok(Some(p)) = tree::get_path_entry(&transaction, odb, mtree, &self.path) { - return Ok(linecount(&transaction, odb, objects::git2_oid(&p.oid)) as i32); + return Ok(linecount(&transaction, odb, p.oid.to_owned()) as i32); } else if self.path == std::path::Path::new("") { return Ok(linecount(&transaction, odb, mtree) as i32); } @@ -663,7 +667,7 @@ impl Path { fn internal_serialize( &self, context: &Context, - to_result: impl FnOnce(&cache::Transaction, git2::Oid) -> FieldResult, + to_result: impl FnOnce(&cache::Transaction, gix_hash::ObjectId) -> FieldResult, ) -> FieldResult { let transaction = context.transaction.lock().unwrap(); @@ -673,7 +677,7 @@ impl Path { let odb = transaction.odb(); let entry = tree::get_path_entry(&transaction, odb, self.tree, &self.path)? .ok_or_else(|| anyhow!("no such path: {}", self.path.display()))?; - objects::git2_oid(&entry.oid) + entry.oid.to_owned() }; to_result(&transaction, id) } @@ -760,7 +764,7 @@ impl Path { } pub struct Document { - id: git2::Oid, + id: gix_hash::ObjectId, value: serde_json::Value, } @@ -808,7 +812,7 @@ impl Document { if let serde_json::Value::Array(a) = &self.pointer(at) { for x in a.iter() { v.push(Document { - id: git2::Oid::ZERO_SHA1, + id: gix_hash::ObjectId::null(gix_hash::Kind::Sha1), value: x.clone(), }); } @@ -820,7 +824,7 @@ impl Document { fn value(&self, at: String) -> Option { self.value.pointer(&at).map(|x| Document { - id: git2::Oid::ZERO_SHA1, + id: gix_hash::ObjectId::null(gix_hash::Kind::Sha1), value: x.to_owned(), }) } @@ -858,7 +862,7 @@ impl Reference { } type ToPushSet = std::sync::Arc< - std::sync::Mutex)>>, + std::sync::Mutex)>>, >; #[derive(PartialEq, Eq, Clone, Copy)] @@ -941,8 +945,11 @@ impl RevMut { fn push(&self, target: String, repo: Option, context: &Context) -> FieldResult { let transaction = context.transaction.lock().unwrap(); - let filter_commit = - filtered_commit(&transaction, self.filter, git2::Oid::from_str(&self.at)?)?; + let filter_commit = filtered_commit( + &transaction, + self.filter, + gix_hash::ObjectId::from_str(&self.at)?, + )?; if let Ok(mut to_push) = context.to_push.lock() { to_push.insert((filter_commit.id(), target, repo)); @@ -990,7 +997,7 @@ impl RepositoryMut { let transaction_mirror = context.transaction_mirror.lock().unwrap(); // Just check that the commit exists - CommitData::read(transaction_mirror.odb(), git2::Oid::from_str(&at)?)?; + CommitData::read(transaction_mirror.odb(), gix_hash::ObjectId::from_str(&at)?)?; let filter = if let Some(spec) = filter { filter::parse(&spec)? @@ -1045,7 +1052,7 @@ impl Repository { let transaction_mirror = context.transaction_mirror.lock().unwrap(); let commit_id = { - let oid = if let Ok(id) = git2::Oid::from_str(&at) { + let oid = if let Ok(id) = gix_hash::ObjectId::from_str(&at) { Some((id, transaction_mirror.odb().contains(id))) } else { None @@ -1097,7 +1104,7 @@ pub fn context(transaction: cache::Transaction, transaction_mirror: cache::Trans pub type CommitSchema = juniper::RootNode, EmptySubscription>; -pub fn commit_schema(commit_id: git2::Oid) -> CommitSchema { +pub fn commit_schema(commit_id: gix_hash::ObjectId) -> CommitSchema { CommitSchema::new( Revision { commit_id, diff --git a/josh-gui/Cargo.lock b/josh-gui/Cargo.lock index 494539343..61aee952b 100644 --- a/josh-gui/Cargo.lock +++ b/josh-gui/Cargo.lock @@ -138,7 +138,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -149,7 +149,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1768,7 +1768,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2001,7 +2001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4205,6 +4205,7 @@ dependencies = [ "anyhow", "chrono", "git2", + "gix-hash", "josh-core", "josh-git-serde", "serde", @@ -4265,7 +4266,6 @@ name = "josh-filter" version = "26.7.28" dependencies = [ "anyhow", - "git2", "gix-hash", "gix-object", "glob", @@ -4285,9 +4285,8 @@ name = "josh-git-serde" version = "26.7.28" dependencies = [ "anyhow", - "git2", + "gix-hash", "gix-object", - "josh-gix-ext", "percent-encoding", "serde", "thiserror 2.0.20", @@ -4321,7 +4320,7 @@ name = "josh-github-changes" version = "26.7.28" dependencies = [ "anyhow", - "git2", + "gix-hash", "josh-changes", "josh-core", "josh-git-serde", @@ -4353,7 +4352,7 @@ version = "26.7.28" dependencies = [ "anyhow", "async-trait", - "git2", + "gix-hash", "graphql_client", "http", "josh-github-auth", @@ -4411,6 +4410,7 @@ dependencies = [ "clap", "dioxus", "git2", + "gix-hash", "josh-changes", "josh-core", "josh-github-changes", @@ -4424,14 +4424,12 @@ name = "josh-memodb" version = "26.7.28" dependencies = [ "anyhow", - "git2", "gix-features", "gix-hash", "gix-object", "gix-odb", "gix-pack", "gix-zlib", - "josh-gix-ext", "log", "parking_lot 0.12.5", "tempfile", @@ -4442,7 +4440,6 @@ name = "josh-search" version = "26.7.28" dependencies = [ "anyhow", - "git2", "gix-hash", "gix-object", ] @@ -4453,7 +4450,7 @@ version = "26.7.28" dependencies = [ "allocative", "anyhow", - "git2", + "gix-hash", "gix-object", "josh-filter", "josh-gix-ext", @@ -6046,7 +6043,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6532,7 +6529,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6589,7 +6586,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7132,7 +7129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7536,10 +7533,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8395,7 +8392,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/josh-gui/Cargo.toml b/josh-gui/Cargo.toml index 635f1c2da..7262f453c 100644 --- a/josh-gui/Cargo.toml +++ b/josh-gui/Cargo.toml @@ -20,6 +20,7 @@ josh-changes = { path = "../josh-changes" } josh-core = { path = "../josh-core" } josh-github-changes = { path = "../forges/josh-github-changes" } git2 = { version = "0.21.0", default-features = false, features = ["vendored-libgit2"] } +gix-hash = { version = "^0.26", features = ["sha1"] } tokio = { version = "1", features = ["time"] } # Same as the root workspace: use the vendored glob fork, which keeps the diff --git a/josh-gui/src/detail.rs b/josh-gui/src/detail.rs index be9801b43..71015c4bc 100644 --- a/josh-gui/src/detail.rs +++ b/josh-gui/src/detail.rs @@ -1,4 +1,5 @@ use dioxus::prelude::*; +use std::str::FromStr; use crate::Page; use crate::common::{ @@ -46,7 +47,7 @@ pub struct PrInfo { #[component] pub fn DetailView(sha: String, scope: josh_changes::ChangesRef, mut page: Signal) -> Element { - let changes_ref_oid = use_context::>>(); + let changes_ref_oid = use_context::>>(); // Establish a reactive dependency on ref changes. let _ = changes_ref_oid.read(); let data = load_detail(&sha, &scope); @@ -289,9 +290,9 @@ pub fn DetailView(sha: String, scope: josh_changes::ChangesRef, mut page: Signal pub fn load_detail(sha: &str, scope: &josh_changes::ChangesRef) -> anyhow::Result { let transaction = crate::common::open_transaction()?; - let oid = git2::Oid::from_str(sha)?; + let oid = gix_hash::ObjectId::from_str(sha)?; let repo = transaction.git2_repo(); - let commit = repo.find_commit(oid)?; + let commit = repo.find_commit(josh_core::objects::git2_oid(&oid))?; let msg = commit.message().unwrap_or(""); let subject = msg.lines().next().unwrap_or("").to_string(); @@ -345,13 +346,13 @@ pub fn load_detail(sha: &str, scope: &josh_changes::ChangesRef) -> anyhow::Resul if let Ok(all) = josh_changes::list_changes(&transaction, scope) { if let Some(c) = all.iter().find(|c| c.id() == Some(cid.as_str())) { let base = c.base(); - if base != git2::Oid::ZERO_SHA1 { + if base != gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { change.set_base(base); } } } for oid in change.contributing(&transaction).unwrap_or_default() { - if let Ok(c) = repo.find_commit(oid) { + if let Ok(c) = repo.find_commit(josh_core::objects::git2_oid(&oid)) { let msg = c.message().unwrap_or(""); let c_subject = msg.lines().next().unwrap_or("").to_string(); let c_author = c.author().email().unwrap_or("").to_string(); @@ -401,7 +402,7 @@ pub fn save_comment( scope: &josh_changes::ChangesRef, ) -> anyhow::Result { let transaction = crate::common::open_transaction()?; - let oid = git2::Oid::from_str(sha)?; + let oid = gix_hash::ObjectId::from_str(sha)?; let change = josh_changes::Change::new(&transaction, oid)?; let meta = josh_changes::CommentMeta { @@ -433,7 +434,7 @@ pub fn save_comment( /// Refresh the shared OID after a local ref mutation. pub fn bump_changes_ref_oid( - mut changes_ref_oid: Signal>, + mut changes_ref_oid: Signal>, scope: &josh_changes::ChangesRef, ) { let new_oid = git2::Repository::discover(".") @@ -451,7 +452,7 @@ pub fn save_vote( scope: &josh_changes::ChangesRef, ) -> anyhow::Result { let transaction = crate::common::open_transaction()?; - let oid = git2::Oid::from_str(sha)?; + let oid = gix_hash::ObjectId::from_str(sha)?; let change = josh_changes::Change::new(&transaction, oid)?; let body_meta = || josh_changes::CommentMeta { diff --git a/josh-gui/src/diff.rs b/josh-gui/src/diff.rs index a7fe272ae..400a2bf92 100644 --- a/josh-gui/src/diff.rs +++ b/josh-gui/src/diff.rs @@ -1,4 +1,5 @@ use dioxus::prelude::*; +use std::str::FromStr; use crate::Page; use crate::common::{FlatComment, parse_hunk_header, render_comment_card}; @@ -49,8 +50,8 @@ fn selected_file_line(items: &[DiffItem], sel: Option) -> Option { fn load_file_diff(sha: &str, path: &str, context_lines: u32) -> anyhow::Result> { let repo = git2::Repository::discover(".")?; - let oid = git2::Oid::from_str(sha)?; - let commit = repo.find_commit(oid)?; + let oid = gix_hash::ObjectId::from_str(sha)?; + let commit = repo.find_commit(josh_core::objects::git2_oid(&oid))?; let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok()); let mut opts = git2::DiffOptions::new(); @@ -202,7 +203,7 @@ pub fn FileDiffView( scope: josh_changes::ChangesRef, mut page: Signal, ) -> Element { - let changes_ref_oid = use_context::>>(); + let changes_ref_oid = use_context::>>(); let mut detail = use_signal(|| detail::load_detail(&sha, &scope)); let mut prev_sha = use_signal(|| sha.clone()); let mut prev_oid = use_signal(|| *changes_ref_oid.peek()); diff --git a/josh-gui/src/list.rs b/josh-gui/src/list.rs index 79c82ed28..f45a7cc24 100644 --- a/josh-gui/src/list.rs +++ b/josh-gui/src/list.rs @@ -45,8 +45,8 @@ pub fn ListView( mut page: Signal, mut selected_change: Signal>, ) -> Element { - let changes_ref_oid = use_context::>>(); - let mut prev_metadata_oid = use_signal(|| None::>); + let changes_ref_oid = use_context::>>(); + let mut prev_metadata_oid = use_signal(|| None::>); { let scope_for_meta = scope.clone(); @@ -325,7 +325,9 @@ pub fn load_rows(scope: &josh_changes::ChangesRef) -> anyhow::Result { let mut dependencies: HashMap> = HashMap::new(); for change in &changes { - let commit = transaction.git2_repo().find_commit(change.commit())?; + let commit = transaction + .git2_repo() + .find_commit(josh_core::objects::git2_oid(&change.commit()))?; let subject = commit .message() .unwrap_or("") diff --git a/josh-gui/src/main.rs b/josh-gui/src/main.rs index 8f2807610..261bc63ef 100644 --- a/josh-gui/src/main.rs +++ b/josh-gui/src/main.rs @@ -76,7 +76,7 @@ fn app() -> Element { let current_scope = use_signal(|| initial_scope.clone()); // Views subscribe to this signal to invalidate ref-derived data. - let mut changes_ref_oid: Signal> = use_signal(|| { + let mut changes_ref_oid: Signal> = use_signal(|| { git2::Repository::discover(".") .ok() .and_then(|r| josh_changes::read_ref_oid(&r, ¤t_scope.read())) diff --git a/josh-link/Cargo.toml b/josh-link/Cargo.toml index bab2398fb..bdce7e489 100644 --- a/josh-link/Cargo.toml +++ b/josh-link/Cargo.toml @@ -10,6 +10,7 @@ keywords = ["git", "monorepo", "workflow", "scm"] [dependencies] git2.workspace = true +gix-hash.workspace = true anyhow.workspace = true josh-core.workspace = true diff --git a/josh-link/src/lib.rs b/josh-link/src/lib.rs index 728a0c24e..803c9007c 100644 --- a/josh-link/src/lib.rs +++ b/josh-link/src/lib.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; /// Prepared link addition, ready to be finalized pub struct PreparedLinkAdd { - tree_oid: git2::Oid, + tree_oid: gix_hash::ObjectId, path: PathBuf, } @@ -15,9 +15,9 @@ impl PreparedLinkAdd { pub fn into_commit( self, transaction: &josh_core::cache::Transaction, - head_commit: git2::Oid, + head_commit: gix_hash::ObjectId, signature: &git2::Signature, - ) -> anyhow::Result { + ) -> anyhow::Result { josh_core::objects::write_commit( transaction.odb(), self.tree_oid, @@ -32,7 +32,7 @@ impl PreparedLinkAdd { /// Get tree OID for custom commit creation /// /// This is used by josh-cq to add additional files before creating a commit - pub fn into_tree_oid(self) -> git2::Oid { + pub fn into_tree_oid(self) -> gix_hash::ObjectId { self.tree_oid } } @@ -40,9 +40,9 @@ impl PreparedLinkAdd { /// Result from updating links pub struct UpdateLinksResult { /// Commit with updated .link.josh files - pub commit_with_updates: git2::Oid, + pub commit_with_updates: gix_hash::ObjectId, /// Commit after applying :link filter - pub filtered_commit: git2::Oid, + pub filtered_commit: gix_hash::ObjectId, } /// A remote URL and commit SHA found in a `.link.josh` file. @@ -56,7 +56,7 @@ pub struct LinkRef { /// all (remote, commit) pairs found in any `.link.josh` file across all commits and trees. pub fn collect_all_link_refs( transaction: &josh_core::cache::Transaction, - commit: git2::Oid, + commit: gix_hash::ObjectId, ) -> anyhow::Result> { // Apply a filter that keeps only .link.josh files. This prunes the history // to only commits that actually changed those files, so the revwalk below @@ -67,7 +67,7 @@ pub fn collect_all_link_refs( let filtered_commit = josh_core::filter_commit(transaction, link_file_filter, commit) .context("Failed to apply .link.josh filter")?; - if filtered_commit == git2::Oid::ZERO_SHA1 { + if filtered_commit == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { return Ok(HashSet::new()); } @@ -122,8 +122,8 @@ pub fn prepare_link_add( url: &str, filter: Option<&str>, target: &str, - fetched_commit: git2::Oid, - head_tree: git2::Oid, + fetched_commit: gix_hash::ObjectId, + head_tree: gix_hash::ObjectId, mode: josh_core::filter::LinkMode, ) -> anyhow::Result { let odb = transaction.odb(); @@ -166,8 +166,8 @@ pub fn prepare_link_add( pub fn update_links( transaction: &josh_core::cache::Transaction, - head_commit: git2::Oid, - links_to_update: Vec<(PathBuf, git2::Oid)>, + head_commit: gix_hash::ObjectId, + links_to_update: Vec<(PathBuf, gix_hash::ObjectId)>, signature: &git2::Signature, ) -> anyhow::Result> { let odb = transaction.odb(); diff --git a/josh-memodb/Cargo.toml b/josh-memodb/Cargo.toml index fa6616850..f7e82447a 100644 --- a/josh-memodb/Cargo.toml +++ b/josh-memodb/Cargo.toml @@ -11,14 +11,16 @@ edition = "2024" [dependencies] anyhow.workspace = true -git2.workspace = true gix-features.workspace = true gix-hash.workspace = true gix-object.workspace = true gix-odb.workspace = true gix-pack.workspace = true gix-zlib.workspace = true -josh-gix-ext.workspace = true log.workspace = true parking_lot.workspace = true tempfile.workspace = true + +[dev-dependencies] +git2.workspace = true +josh-gix-ext.workspace = true diff --git a/josh-memodb/src/flusher.rs b/josh-memodb/src/flusher.rs index f598fd293..6b2f38581 100644 --- a/josh-memodb/src/flusher.rs +++ b/josh-memodb/src/flusher.rs @@ -80,22 +80,18 @@ pub(crate) fn enqueue_chunk(store: Arc) { /// Pack `store` to disk and block until it is done, so the objects are durable before the caller /// proceeds. Any queued chunks for the same store complete first (FIFO on the single worker). -pub(crate) fn drain(store: Arc) -> Result<(), git2::Error> { +pub(crate) fn drain(store: Arc) -> anyhow::Result<()> { let (ack_tx, ack_rx) = sync_channel::>(1); if FLUSHER .sender .send(Job::Drain { store, ack: ack_tx }) .is_err() { - return Err(git2::Error::from_str( - "mem-odb flusher channel disconnected", - )); + return Err(anyhow::anyhow!("mem-odb flusher channel disconnected")); } match ack_rx.recv() { Ok(Ok(())) => Ok(()), - Ok(Err(msg)) => Err(git2::Error::from_str(&msg)), - Err(_) => Err(git2::Error::from_str( - "mem-odb flusher ack channel disconnected", - )), + Ok(Err(msg)) => Err(anyhow::anyhow!(msg)), + Err(_) => Err(anyhow::anyhow!("mem-odb flusher ack channel disconnected")), } } diff --git a/josh-memodb/src/lib.rs b/josh-memodb/src/lib.rs index 16e5e5cd6..20d215ced 100644 --- a/josh-memodb/src/lib.rs +++ b/josh-memodb/src/lib.rs @@ -15,5 +15,4 @@ pub mod registry; pub use hash::PassthroughHasher; pub use mem_odb::MemOdb; pub use odb::{Bytes, Odb}; -pub use pack::objects_dir; pub use registry::FlushGuard; diff --git a/josh-memodb/src/mem_odb.rs b/josh-memodb/src/mem_odb.rs index 7fe44d4e9..593e61d13 100644 --- a/josh-memodb/src/mem_odb.rs +++ b/josh-memodb/src/mem_odb.rs @@ -189,7 +189,7 @@ impl MemOdb { /// ref ends. Everything *readable* through the store has to become durable, so a chained /// store drains what it reads through too. Runs on the background flusher behind any queued /// overflow chunks; a store holding nothing skips the round trip. - pub fn flush(self: &Arc) -> Result<(), git2::Error> { + pub fn flush(self: &Arc) -> anyhow::Result<()> { if let Some(behind) = &self.read_through { behind.flush()?; } @@ -234,7 +234,7 @@ impl MemOdb { /// no repository handle involved. The snapshot shares the object buffers (`Arc`), and the /// objects stay in the map while the pack is written, so concurrent reads keep resolving until /// eviction — which happens only once the pack is on disk. - pub(crate) fn pack_to_disk(self: &Arc) -> Result<(), git2::Error> { + pub(crate) fn pack_to_disk(self: &Arc) -> anyhow::Result<()> { // One packer per store at a time: the flusher's own jobs are already serialised, but the // process-exit flush packs inline and would otherwise snapshot and evict concurrently. let _packing = self.pack_lock.lock(); diff --git a/josh-memodb/src/odb.rs b/josh-memodb/src/odb.rs index be2aac0dc..97a582fad 100644 --- a/josh-memodb/src/odb.rs +++ b/josh-memodb/src/odb.rs @@ -1,8 +1,8 @@ //! The transaction object-database facade: the [`MemOdb`] store consulted directly, backed by //! the repository's gitoxide object database for objects that are not buffered. //! -//! Implements the [`gix_object`] object-access traits (memory-first, disk fallback) plus -//! inherent helpers in `git2::Oid` currency; memory hits hand out zero-copy `Arc` buffers. +//! Implements the [`gix_object`] object-access traits and `ObjectId`-typed inherent helpers +//! (memory-first, disk fallback); memory hits hand out zero-copy `Arc` buffers. //! //! A store resolves `objects/info/alternates` when it opens, so an alternate registered at //! runtime (the proxy overlay's mirror) is a store of its own, consulted after the @@ -137,10 +137,9 @@ impl Odb { .any(|alt| alt.gate.exists(id)) } - /// Read the raw bytes and kind of `oid`; memory hits are zero-copy. A missing object is an + /// Read the raw bytes and kind of `id`; memory hits are zero-copy. A missing object is an /// error, like a plain odb read. - pub fn read(&self, oid: git2::Oid) -> anyhow::Result<(Kind, Bytes)> { - let id = josh_gix_ext::gix_oid(oid); + pub fn read(&self, id: ObjectId) -> anyhow::Result<(Kind, Bytes)> { if let Some((kind, data)) = self.mem.get(&id) { return Ok((kind, Bytes::Mem(data))); } @@ -150,13 +149,13 @@ impl Odb { let mut buffer = Vec::new(); if let Some(kind) = self .find_on_disk(&id, &mut buffer) - .map_err(|e| anyhow::anyhow!("failed to read {}: {}", oid, e))? + .map_err(|e| anyhow::anyhow!("failed to read {}: {}", id, e))? { return Ok((kind, Bytes::Disk(buffer))); } Err(anyhow::anyhow!( "object not found - no match for id ({})", - oid + id )) } @@ -178,30 +177,28 @@ impl Odb { Ok(None) } - /// Kind and size of `oid` without reading (or decompressing) its bytes. - pub fn read_header(&self, oid: git2::Oid) -> anyhow::Result<(Kind, u64)> { - self.header(oid)? - .ok_or_else(|| anyhow::anyhow!("object not found - no match for id ({})", oid)) + /// Kind and size of `id` without reading (or decompressing) its bytes. + pub fn read_header(&self, id: ObjectId) -> anyhow::Result<(Kind, u64)> { + self.header(id)? + .ok_or_else(|| anyhow::anyhow!("object not found - no match for id ({})", id)) } - pub fn contains(&self, oid: git2::Oid) -> bool { - let id = josh_gix_ext::gix_oid(oid); + pub fn contains(&self, id: ObjectId) -> bool { self.mem.contains(&id) || self.disk.exists(&id) || self.in_alternate(&id) } - /// Kind of `oid`, or `None` if the object does not exist. Never decompresses. Resolves the + /// Kind of `id`, or `None` if the object does not exist. Never decompresses. Resolves the /// empty tree even when it is stored nowhere, so probes on possibly-empty trees belong /// here and never on [`contains`](Odb::contains). - pub fn try_kind(&self, oid: git2::Oid) -> anyhow::Result> { - Ok(self.header(oid)?.map(|(kind, _)| kind)) + pub fn try_kind(&self, id: ObjectId) -> anyhow::Result> { + Ok(self.header(id)?.map(|(kind, _)| kind)) } - /// Kind and size of `oid`, or `None` when no store holds it. - fn header(&self, oid: git2::Oid) -> anyhow::Result> { - let id = josh_gix_ext::gix_oid(oid); + /// Kind and size of `id`, or `None` when no store holds it. + fn header(&self, id: ObjectId) -> anyhow::Result> { Ok(self .try_header(&id) - .map_err(|e| anyhow::anyhow!("failed to read header of {}: {}", oid, e))? + .map_err(|e| anyhow::anyhow!("failed to read header of {}: {}", id, e))? .map(|header| (header.kind, header.size))) } @@ -212,11 +209,11 @@ impl Odb { /// the repository's own on-disk objects: objects already durable there are buffered anyway /// and dropped at pack time, and a repository without alternates writes with zero /// filesystem I/O. - pub fn write(&self, kind: Kind, data: &[u8]) -> git2::Oid { + pub fn write(&self, kind: Kind, data: &[u8]) -> ObjectId { let id = gix_object::compute_hash(gix_hash::Kind::Sha1, kind, data) .expect("failed to compute hash"); self.write_with_id(id, kind, data); - josh_gix_ext::git2_oid(&id) + id } /// [`Odb::write`] with a caller-computed content hash, trusted verbatim. @@ -296,7 +293,7 @@ impl gix_object::Exists for Odb { impl gix_object::Write for Odb { fn write_buf(&self, object: Kind, from: &[u8]) -> Result { - Ok(josh_gix_ext::gix_oid(self.write(object, from))) + Ok(self.write(object, from)) } fn write_buf_with_known_id( @@ -360,11 +357,17 @@ mod tests { // Not yet on disk. let fresh = git2::Repository::open(dir.path()).unwrap(); - assert!(fresh.find_blob(oid).is_err()); + assert!(fresh.find_blob(josh_gix_ext::git2_oid(&oid)).is_err()); store.flush().unwrap(); let fresh = git2::Repository::open(dir.path()).unwrap(); - assert_eq!(fresh.find_blob(oid).unwrap().content(), b"facade blob"); + assert_eq!( + fresh + .find_blob(josh_gix_ext::git2_oid(&oid)) + .unwrap() + .content(), + b"facade blob" + ); } /// The write gate: an object already in a registered runtime alternate is not buffered @@ -389,14 +392,14 @@ mod tests { // Alternate hit: skipped, still readable through the alternate. let oid = odb.write(Kind::Blob, b"mirror blob"); - assert_eq!(oid, in_alternate); - assert!(!store.contains(&josh_gix_ext::gix_oid(oid))); + assert_eq!(oid, josh_gix_ext::gix_oid(in_alternate)); + assert!(!store.contains(&oid)); assert!(matches!(odb.read(oid).unwrap().1, Bytes::Disk(_))); // Main-disk object: buffered — the gate does not probe the repository's own objects. let oid = odb.write(Kind::Blob, b"already on disk"); - assert_eq!(oid, on_disk); - assert!(store.contains(&josh_gix_ext::gix_oid(oid))); + assert_eq!(oid, josh_gix_ext::gix_oid(on_disk)); + assert!(store.contains(&oid)); assert!(matches!(odb.read(oid).unwrap().1, Bytes::Mem(_))); } @@ -409,7 +412,7 @@ mod tests { let mirror_dir = crate::pack::objects_dir(&mirror); // Packed before the alternate is registered. let mirror_store = MemOdb::new(None, mirror_dir.clone()); - let packed = josh_gix_ext::git2_oid(&mirror_store.write(Kind::Blob, b"packed in mirror")); + let packed = mirror_store.write(Kind::Blob, b"packed in mirror"); mirror_store.flush().unwrap(); let repo = git2::Repository::init(tmp.path().join("overlay")).unwrap(); @@ -418,14 +421,14 @@ mod tests { odb.add_alternate(&mirror_dir).unwrap(); assert_eq!(odb.write(Kind::Blob, b"packed in mirror"), packed); - assert!(!store.contains(&josh_gix_ext::gix_oid(packed))); + assert!(!store.contains(&packed)); // Packed after registration: the gate does not go looking, so it buffers a duplicate. - let later = josh_gix_ext::git2_oid(&mirror_store.write(Kind::Blob, b"packed later")); - let unread = josh_gix_ext::git2_oid(&mirror_store.write(Kind::Blob, b"never written")); + let later = mirror_store.write(Kind::Blob, b"packed later"); + let unread = mirror_store.write(Kind::Blob, b"never written"); mirror_store.flush().unwrap(); assert_eq!(odb.write(Kind::Blob, b"packed later"), later); - assert!(store.contains(&josh_gix_ext::gix_oid(later))); + assert!(store.contains(&later)); // Reads do go looking, and find an object from that same pack. assert_eq!(&*odb.read(unread).unwrap().1, b"never written"); @@ -441,15 +444,19 @@ mod tests { let store = MemOdb::new(None, crate::pack::objects_dir(&repo)); let odb = facade(&store, &repo); - let empty_tree = git2::Oid::from_str("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap(); - assert!(!store.contains(&josh_gix_ext::gix_oid(empty_tree))); + let empty_tree = josh_gix_ext::gix_oid( + git2::Oid::from_str("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap(), + ); + assert!(!store.contains(&empty_tree)); assert_eq!(odb.try_kind(empty_tree).unwrap(), Some(Kind::Tree)); assert_eq!(odb.read(empty_tree).unwrap().0, Kind::Tree); assert!(odb.read(empty_tree).unwrap().1.is_empty()); assert!(!odb.contains(empty_tree)); // A genuinely absent object is None; a buffered object reports its memory kind. - let absent = git2::Oid::from_str("0123456789012345678901234567890123456789").unwrap(); + let absent = josh_gix_ext::gix_oid( + git2::Oid::from_str("0123456789012345678901234567890123456789").unwrap(), + ); assert_eq!(odb.try_kind(absent).unwrap(), None); assert!(odb.read(absent).is_err()); let blob = odb.write(Kind::Blob, b"probe"); @@ -468,13 +475,10 @@ mod tests { let oid = odb.write(Kind::Blob, b"facade blob"); let mut buf = Vec::new(); - let data = odb - .try_find(&josh_gix_ext::gix_oid(oid), &mut buf) - .unwrap() - .unwrap(); + let data = odb.try_find(&oid, &mut buf).unwrap().unwrap(); assert_eq!(data.kind, Kind::Blob); assert_eq!(data.data, b"facade blob"); - assert!(odb.exists(&josh_gix_ext::gix_oid(oid))); + assert!(odb.exists(&oid)); } /// A pack written after the facade was built is still found, which is how a flush, a @@ -487,9 +491,8 @@ mod tests { let odb = facade(&store, &repo); // Miss first, so the store has settled on the packs it found at open. - let oid = gix_object::compute_hash(gix_hash::Kind::Sha1, Kind::Blob, b"packed later") - .map(|id| josh_gix_ext::git2_oid(&id)) - .unwrap(); + let oid = + gix_object::compute_hash(gix_hash::Kind::Sha1, Kind::Blob, b"packed later").unwrap(); assert!(!odb.contains(oid)); let store2 = MemOdb::new(None, crate::pack::objects_dir(&repo)); diff --git a/josh-memodb/src/pack.rs b/josh-memodb/src/pack.rs index cbdfb939f..289c8bc84 100644 --- a/josh-memodb/src/pack.rs +++ b/josh-memodb/src/pack.rs @@ -6,8 +6,9 @@ //! on disk are filtered out first, so a pack contains only genuinely-new objects and the on-disk //! layout stays deterministic. +use anyhow::Context; use std::io::{Seek, Write}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::atomic::AtomicBool; use gix_object::Exists; @@ -15,20 +16,11 @@ use gix_pack::data::output; use crate::mem_odb::Snapshot; -/// The directory where `repo`'s objects live (`/objects`), captured at -/// [`MemOdb::new`](crate::mem_odb::MemOdb::new) time while a repository handle exists. -/// -/// Resolved from the repository's *common* directory rather than its gitdir: a linked worktree has -/// no `objects/` of its own, so its objects live under the common dir. For a non-worktree repo the -/// two are the same. -pub fn objects_dir(repo: &git2::Repository) -> PathBuf { +#[cfg(test)] +pub(crate) fn objects_dir(repo: &git2::Repository) -> std::path::PathBuf { repo.commondir().join("objects") } -fn pack_error(e: impl std::fmt::Display) -> git2::Error { - git2::Error::from_str(&format!("mem-odb pack write failed: {e}")) -} - /// Compress and write the objects of `snapshot` that are not already present in `objects_dir` /// (loose, packed, or via alternates) as a single packfile-plus-index pair in /// `objects_dir/pack`. A no-op if every object is already on disk. @@ -38,12 +30,12 @@ fn pack_error(e: impl std::fmt::Display) -> git2::Error { /// trailer checksum (the same rule libgit2 and modern git use), so identical snapshots produce /// identical packs. Files are written via tempfile-and-rename, index last, so a concurrent /// reader scanning for `.idx` files never sees a torn pair. -pub(crate) fn write_snapshot(objects_dir: &Path, snapshot: &Snapshot) -> Result<(), git2::Error> { +pub(crate) fn write_snapshot(objects_dir: &Path, snapshot: &Snapshot) -> anyhow::Result<()> { // A fresh store handle per flush observes every pack written by previous flushes. Misses are // the expected case below, and the default refresh mode re-lists the pack directory on every // miss — disable it; the first lookup still loads all indices present now, and loose-object // probes stat the filesystem directly either way. - let mut odb = gix_odb::at(objects_dir.to_owned()).map_err(pack_error)?; + let mut odb = gix_odb::at(objects_dir.to_owned()).context("mem-odb pack write failed")?; odb.refresh = gix_odb::store::RefreshMode::Never; let to_pack: Vec<_> = snapshot @@ -54,18 +46,19 @@ pub(crate) fn write_snapshot(objects_dir: &Path, snapshot: &Snapshot) -> Result< if to_pack.is_empty() { return Ok(()); } - let num_entries = u32::try_from(to_pack.len()) - .map_err(|_| pack_error("more objects in one flush than a pack header can count"))?; + let num_entries = u32::try_from(to_pack.len()).context("mem-odb pack write failed")?; let pack_dir = objects_dir.join("pack"); - std::fs::create_dir_all(&pack_dir).map_err(pack_error)?; + std::fs::create_dir_all(&pack_dir).context("mem-odb pack write failed")?; // Serialize the pack byte stream (header, compressed entries, checksum trailer) through an // anonymous spool file in the pack directory: objects are compressed one at a time as the // serializer pulls them, so no more than one compressed object is ever held in memory — // a snapshot's size is only *typically* bounded by the store's chunk limit (unbounded stores // exist, and the limit is an overflow trigger, not a cap). - let mut spool = std::io::BufWriter::new(tempfile::tempfile_in(&pack_dir).map_err(pack_error)?); + let mut spool = std::io::BufWriter::new( + tempfile::tempfile_in(&pack_dir).context("mem-odb pack write failed")?, + ); let mut iter = output::bytes::FromEntriesIter::new( to_pack.iter().map(|(oid, kind, data)| { output::Entry::from_data( @@ -82,12 +75,12 @@ pub(crate) fn write_snapshot(objects_dir: &Path, snapshot: &Snapshot) -> Result< gix_hash::Kind::Sha1, ); for written in &mut iter { - written.map_err(pack_error)?; + written.context("mem-odb pack write failed")?; } drop(iter); - spool.flush().map_err(pack_error)?; - let mut spool = spool.into_inner().map_err(pack_error)?; - spool.rewind().map_err(pack_error)?; + spool.flush().context("mem-odb pack write failed")?; + let mut spool = spool.into_inner().context("mem-odb pack write failed")?; + spool.rewind().context("mem-odb pack write failed")?; let outcome = gix_pack::Bundle::write_to_directory( &mut std::io::BufReader::new(spool), @@ -105,7 +98,7 @@ pub(crate) fn write_snapshot(objects_dir: &Path, snapshot: &Snapshot) -> Result< compression: gix_zlib::Compression::DEFAULT, }, ) - .map_err(pack_error)?; + .context("mem-odb pack write failed")?; // gix marks the freshly-landed pack with a `.keep` file for the caller to remove once its // referencing refs exist. josh's flushes carry no such handshake (libgit2's packbuilder wrote // no `.keep` either), and a leftover one would exempt the pack from `git repack -d` forever. @@ -116,7 +109,7 @@ pub(crate) fn write_snapshot(objects_dir: &Path, snapshot: &Snapshot) -> Result< if let Some(data_path) = &outcome.data_path { if let Err(e) = std::fs::remove_file(data_path.with_extension("keep")) { if e.kind() != std::io::ErrorKind::NotFound { - return Err(pack_error(e)); + return Err(e).context("mem-odb pack write failed"); } } } diff --git a/josh-proxy/Cargo.toml b/josh-proxy/Cargo.toml index 354dcead8..aacbeebe5 100644 --- a/josh-proxy/Cargo.toml +++ b/josh-proxy/Cargo.toml @@ -47,10 +47,13 @@ toml.workspace = true tracing.workspace = true tracing-subscriber.workspace = true tokio-util.workspace = true +dunce.workspace = true +socket2.workspace = true tempfile.workspace = true gix.workspace = true juniper.workspace = true git2.workspace = true +gix-hash.workspace = true url.workspace = true josh-changes.workspace = true diff --git a/josh-proxy/src/bin/josh-proxy.rs b/josh-proxy/src/bin/josh-proxy.rs index a365b550c..3801e4935 100644 --- a/josh-proxy/src/bin/josh-proxy.rs +++ b/josh-proxy/src/bin/josh-proxy.rs @@ -177,7 +177,7 @@ async fn run_proxy(args: josh_proxy::cli::Args) -> anyhow::Result { let (shutdown_tx, _shutdown_rx) = broadcast::channel(1); let addr: SocketAddr = format!("[::]:{}", args.port).parse()?; - let listener = tokio::net::TcpListener::bind(addr).await?; + let listener = make_listener(addr)?; let server_future = async move { axum::serve(listener, app).await.context("Server error") }; @@ -252,6 +252,37 @@ fn update_hook(refname: &str, old: &str, new: &str) -> anyhow::Result { } } +/// Bind the listener dual-stack: a bare [::] socket accepts IPv4 on Linux, where bindv6only +/// defaults off, but is v6-only on Windows. +fn make_listener(addr: SocketAddr) -> anyhow::Result { + let socket = socket2::Socket::new( + socket2::Domain::IPV6, + socket2::Type::STREAM, + Some(socket2::Protocol::TCP), + )?; + socket.set_only_v6(false)?; + socket.bind(&addr.into())?; + socket.listen(1024)?; + socket.set_nonblocking(true)?; + Ok(tokio::net::TcpListener::from_std(socket.into())?) +} + +/// The hook this process was invoked as, for the Windows shim hooks. On unix the hooks are +/// symlinks and argv[0] names them, so the environment is not consulted. +fn hook_from_env() -> Option<&'static str> { + #[cfg(windows)] + { + static JOSH_PROXY_HOOK: std::sync::LazyLock> = + std::sync::LazyLock::new(|| std::env::var("JOSH_PROXY_HOOK").ok()); + JOSH_PROXY_HOOK.as_deref() + } + + #[cfg(not(windows))] + { + None + } +} + fn pre_receive_hook() -> anyhow::Result { let repo_update = repo_update_from_env()?; @@ -286,13 +317,13 @@ fn main() -> std::process::ExitCode { // process to do the actual computation while taking advantage of the // cached data already loaded into the main process's memory. if let [a0, a1, a2, a3, ..] = &std::env::args().collect::>().as_slice() - && a0.ends_with("/update") + && (a0.ends_with("/update") || hook_from_env() == Some("update")) { return std::process::ExitCode::from(update_hook(a1, a2, a3).unwrap_or(1) as u8); } if let [a0, ..] = &std::env::args().collect::>().as_slice() - && a0.ends_with("/pre-receive") + && (a0.ends_with("/pre-receive") || hook_from_env() == Some("pre-receive")) { eprintln!("josh-proxy: pre-receive hook"); return std::process::ExitCode::from(match pre_receive_hook() { diff --git a/josh-proxy/src/lib.rs b/josh-proxy/src/lib.rs index 7a5f9627d..600e2280b 100644 --- a/josh-proxy/src/lib.rs +++ b/josh-proxy/src/lib.rs @@ -16,7 +16,6 @@ pub(crate) const MAX_MEM_PACK_SIZE: usize = 128 * 1024 * 1024; use crate::http::{IntoRetryable, RetryableError}; use crate::upstream::RemoteAuth; -use josh_core; josh_core::regex_parsed!( FilteredRepoUrl, @@ -24,11 +23,6 @@ josh_core::regex_parsed!( [api, upstream_repo, filter_spec, pathinfo, headref, rest] ); -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)] -pub struct Ref { - pub target: josh_core::Oid, -} - fn make_ssh_command() -> String { let ssh_options = [ "LogLevel=ERROR", @@ -293,7 +287,7 @@ pub fn merge_meta( transaction: &josh_core::cache::Transaction, transaction_mirror: &josh_core::cache::Transaction, meta_add: &std::collections::HashMap>, -) -> anyhow::Result> { +) -> anyhow::Result> { if meta_add.is_empty() { return Ok(None); } @@ -310,11 +304,8 @@ pub fn merge_meta( for (path, add_lines) in meta_add.iter() { let prev = match josh_core::filter::tree::get_path_entry(transaction, odb, tree, path)? { Some(entry) => { - let blob = josh_core::filter::tree::blob_bytes( - odb, - josh_core::objects::git2_oid(&entry.oid), - ) - .ok_or_else(|| anyhow!("not a blob: {}", entry.oid))?; + let blob = josh_core::filter::tree::blob_bytes(odb, entry.oid.to_owned()) + .ok_or_else(|| anyhow!("not a blob: {}", entry.oid))?; std::str::from_utf8(&blob)?.to_owned() } None => "".to_owned(), diff --git a/josh-proxy/src/service.rs b/josh-proxy/src/service.rs index fa78047a6..a59014c85 100644 --- a/josh-proxy/src/service.rs +++ b/josh-proxy/src/service.rs @@ -399,6 +399,38 @@ fn create_repo_base(path: &PathBuf) -> anyhow::Result { Ok(shell) } +#[cfg(unix)] +fn install_hook(josh_executable: &std::path::Path, hook: &std::path::Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(josh_executable, hook) +} + +/// Install the josh executable as a git hook, as a shim: symlinking requires elevated +/// privileges on Windows. Dispatch keys off argv[0], which a shim cannot fake, so the shim +/// names the hook in JOSH_PROXY_HOOK instead. +#[cfg(windows)] +fn install_hook(josh_executable: &std::path::Path, hook: &std::path::Path) -> std::io::Result<()> { + use std::fmt::Write as _; + + let name = hook + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + // Absolute, because git runs hooks with GIT_DIR as the working directory; quoted, so no + // part of the path is a shell expansion. + let exe = dunce::canonicalize(josh_executable) + .unwrap_or_else(|_| josh_executable.to_path_buf()) + .to_string_lossy() + .replace('\\', "/") + .replace('\'', r"'\''"); + + let mut script = String::new(); + let _ = writeln!(script, "#!/bin/sh"); + let _ = writeln!(script, "JOSH_PROXY_HOOK={name}"); + let _ = writeln!(script, "export JOSH_PROXY_HOOK"); + let _ = writeln!(script, "exec '{exe}' \"$@\""); + std::fs::write(hook, script) +} + pub fn create_repo( path: &std::path::Path, josh_executable: Option<&std::path::Path>, @@ -409,23 +441,20 @@ pub fn create_repo( let overlay_path = path.join("overlay"); tracing::debug!("init overlay repo: {:?}", overlay_path); - let overlay_shell = create_repo_base(&overlay_path)?; - overlay_shell.command(&["mkdir", "hooks"]); + create_repo_base(&overlay_path)?; + std::fs::create_dir_all(overlay_path.join("hooks")).expect("can't create hooks dir"); let josh_executable = josh_executable .map(|p| p.to_path_buf()) .unwrap_or_else(|| std::env::current_exe().expect("can't find path to exe")); - std::os::unix::fs::symlink( - josh_executable.clone(), - overlay_path.join("hooks").join("update"), - ) - .expect("can't symlink update hook"); + install_hook(&josh_executable, &overlay_path.join("hooks").join("update")) + .expect("can't install update hook"); - std::os::unix::fs::symlink( - josh_executable, - overlay_path.join("hooks").join("pre-receive"), + install_hook( + &josh_executable, + &overlay_path.join("hooks").join("pre-receive"), ) - .expect("can't symlink pre-receive hook"); + .expect("can't install pre-receive hook"); if std::env::var_os("JOSH_KEEP_NS").is_none() { std::fs::remove_dir_all(overlay_path.join("refs/namespaces")).ok(); @@ -498,7 +527,7 @@ fn resolve_upstream_ref( transaction: &josh_core::cache::Transaction, repo: &str, ref_value: &str, -) -> anyhow::Result { +) -> anyhow::Result { let josh_name = format!( "refs/josh/upstream/{}/{}", josh_core::to_ns(repo), @@ -513,7 +542,7 @@ fn resolve_upstream_ref( pub struct NamespacedRefs { transaction: josh_core::cache::Transaction, ns: Arc, - refs: Vec<(String, git2::Oid)>, + refs: Vec<(String, gix_hash::ObjectId)>, head_symref: (String, String), } @@ -540,7 +569,7 @@ impl NamespacedRefs { Ok(()) } - pub fn into_inner(self) -> (Vec<(String, git2::Oid)>, (String, String)) { + pub fn into_inner(self) -> (Vec<(String, gix_hash::ObjectId)>, (String, String)) { (self.refs, self.head_symref) } } @@ -642,7 +671,7 @@ async fn filter_to_namespace( let (filtered_refs, _) = josh_core::filter_refs(&t2, filter, &refs_to_filter); let populate_refs = filtered_refs .iter() - .any(|(refn, oid)| refn == &head_symref_target && !oid.is_zero()); + .any(|(refn, oid)| refn == &head_symref_target && !oid.is_null()); let head_symref = ("HEAD".to_string(), head_symref_target); @@ -655,7 +684,7 @@ async fn filter_to_namespace( let namespaced_refs = if populate_refs { filtered_refs .into_iter() - .filter(|(_, oid)| !oid.is_zero()) + .filter(|(_, oid)| !oid.is_null()) .collect() } else { Default::default() @@ -737,6 +766,21 @@ async fn ssh_list_refs( Ok(refs) } +/// SSH serving relays git's stdio over the unix sockets josh-ssh-shell sets up, so there is +/// nothing to connect to here. +#[cfg(not(unix))] +async fn serve_namespace( + _params: &josh_rpc::calls::ServeNamespace, + _repo_path: std::path::PathBuf, + _namespace: &str, + _repo_update: RepoUpdate, +) -> anyhow::Result<()> { + Err(anyhow!( + "SSH serving requires unix sockets, which this platform does not support" + )) +} + +#[cfg(unix)] async fn serve_namespace( params: &josh_rpc::calls::ServeNamespace, repo_path: std::path::PathBuf, @@ -883,7 +927,7 @@ async fn serve_namespace( pub enum HeadRef { ExplicitHead, ExplicitRef(String), - ExplicitSha(String, git2::Oid), + ExplicitSha(String, gix_hash::ObjectId), Implicit, } @@ -909,7 +953,7 @@ impl FromStr for HeadRef { "HEAD" => HeadRef::ExplicitHead, r if r.starts_with("refs/") => HeadRef::ExplicitRef(r.into()), r => { - if let Ok(oid) = git2::Oid::from_str(&r) { + if let Ok(oid) = gix_hash::ObjectId::from_str(&r) { HeadRef::ExplicitSha(r.to_string(), oid) } else { return Err(anyhow!("failed to parse ref")); @@ -1340,7 +1384,7 @@ async fn serve_render_template( .unwrap(), )?; - let commit_id = if let Ok(oid) = git2::Oid::from_str(&head_ref) { + let commit_id = if let Ok(oid) = gix_hash::ObjectId::from_str(&head_ref) { oid } else { transaction_mirror diff --git a/josh-proxy/src/upstream.rs b/josh-proxy/src/upstream.rs index 86b9e2d36..f5d4f7a51 100644 --- a/josh-proxy/src/upstream.rs +++ b/josh-proxy/src/upstream.rs @@ -3,6 +3,7 @@ use crate::service::{JoshProxyService, UpstreamProtocol}; use crate::{FetchError, auth, run_git_with_auth}; use anyhow::{Context, anyhow}; use backon::BackoffBuilder; +use std::str::FromStr; use josh_changes::{PushMode, baseref_and_options, build_to_push}; use josh_core::cache::{CacheStack, TransactionContext}; @@ -339,11 +340,11 @@ pub fn process_repo_update(repo_update: RepoUpdate) -> anyhow::Result { transaction .add_disk_alternate(transaction_mirror.path().join("objects").to_str().unwrap())?; - let old = git2::Oid::from_str(old)?; + let old = gix_hash::ObjectId::from_str(old)?; let author = push_options.author.as_deref().unwrap_or(""); let (baseref, push_to, options, push_mode) = baseref_and_options(refname, author)?; - let old = if old == git2::Oid::ZERO_SHA1 { + let old = if old == gix_hash::ObjectId::null(gix_hash::Kind::Sha1) { let rev = format!("refs/namespaces/{}/{}", repo_update.git_ns, &baseref); let oid = transaction.resolve_ref(&rev)?.unwrap_or(old); @@ -412,7 +413,7 @@ pub fn process_repo_update(repo_update: RepoUpdate) -> anyhow::Result { }; let filter = josh_core::filter::parse(&repo_update.filter_spec)?; - let new_oid = git2::Oid::from_str(new)?; + let new_oid = gix_hash::ObjectId::from_str(new)?; let backward_new_oid = { let unapply_result = josh_core::history::unapply_filter( &transaction, @@ -546,7 +547,7 @@ pub fn process_repo_update(repo_update: RepoUpdate) -> anyhow::Result { pub fn push_head_url( transaction: &josh_core::cache::Transaction, alternate: &str, - oid: git2::Oid, + oid: gix_hash::ObjectId, refname: &str, url: &str, remote_auth: &RemoteAuth, diff --git a/josh-rpc/src/lib.rs b/josh-rpc/src/lib.rs index a6ecda363..47872601a 100644 --- a/josh-rpc/src/lib.rs +++ b/josh-rpc/src/lib.rs @@ -1,2 +1,4 @@ pub mod calls; +// Raw-fd async IO for the SSH shell; unix-only by nature (RawFd, fcntl). +#[cfg(unix)] pub mod tokio_fd; diff --git a/josh-search/Cargo.toml b/josh-search/Cargo.toml index c6a968ee6..127e73cb8 100644 --- a/josh-search/Cargo.toml +++ b/josh-search/Cargo.toml @@ -15,12 +15,12 @@ harness = false [dependencies] anyhow.workspace = true -git2.workspace = true gix-hash.workspace = true gix-object.workspace = true +josh-gix-ext.workspace = true [dev-dependencies] -josh-gix-ext.workspace = true +git2.workspace = true gix.workspace = true criterion2 = { version = "3.0.4" } rand = "0.10.2" diff --git a/josh-search/benches/trigram.rs b/josh-search/benches/trigram.rs index 02cadd7cf..d7eaa03b3 100644 --- a/josh-search/benches/trigram.rs +++ b/josh-search/benches/trigram.rs @@ -3,6 +3,7 @@ use josh_core::git::josh_commit_signature; use josh_test_support::bench::{EntryKind, git2_oid, gix_oid}; use rand::prelude::*; use std::path::PathBuf; +use std::str::FromStr; // Benchmarks for the trigram indexer (`josh_search`), covering its three // externally meaningful operations through the public API only, so the numbers stay comparable @@ -84,14 +85,14 @@ const JOSH_BENCH_COMMIT_TIME: &str = "1700000000"; /// bookkeeping. struct Case { n_files: usize, - chain: Vec, - index_tree_oid: git2::Oid, - chain_indexes: Vec<(git2::Oid, git2::Oid)>, + chain: Vec, + index_tree_oid: gix_hash::ObjectId, + chain_indexes: Vec<(gix_hash::ObjectId, gix_hash::ObjectId)>, total_bytes: u64, } impl Case { - fn tip(&self) -> git2::Oid { + fn tip(&self) -> gix_hash::ObjectId { *self.chain.last().expect("chain is never empty") } } @@ -169,7 +170,7 @@ fn build_case( repo: &git2::Repository, vocab: &[String], n_files: usize, -) -> anyhow::Result { +) -> anyhow::Result { let gix_repo = gix::open(repo.path())?; let baseline = repo.treebuilder(None)?.write()?; let mut builder = gix_repo.edit_tree(gix_oid(baseline))?; @@ -226,18 +227,21 @@ fn build_case( "bench case tip", )?; - Ok(head) + Ok(gix_oid(head)) } /// Aggregate every case tip under one index commit. Its oid changes whenever any case head /// changes, making it a faithful content-addressed cache stamp for the entire repo, and it keeps /// all cases reachable so provision_repo's `git prune` retains the full history. -fn build_index(repo: &git2::Repository, heads: &[git2::Oid]) -> anyhow::Result { +fn build_index( + repo: &git2::Repository, + heads: &[gix_hash::ObjectId], +) -> anyhow::Result { let sig = josh_commit_signature()?; let empty_tree = repo.find_tree(repo.treebuilder(None)?.write()?)?; let parents = heads .iter() - .map(|oid| repo.find_commit(*oid)) + .map(|oid| repo.find_commit(git2_oid(*oid))) .collect::, _>>()?; let parent_refs = parents.iter().collect::>(); let index = repo.commit( @@ -248,7 +252,7 @@ fn build_index(repo: &git2::Repository, heads: &[git2::Oid]) -> anyhow::Result anyhow::Res } /// Recover the first-parent chain (root first, tip last) of a case from its tip ref. -fn recover_chain(repo: &git2::Repository, n_files: usize) -> anyhow::Result> { +fn recover_chain( + repo: &git2::Repository, + n_files: usize, +) -> anyhow::Result> { let mut chain = vec![]; let mut oid = repo.refname_to_id(&format!("refs/heads/case_{n_files}"))?; loop { - chain.push(oid); + chain.push(gix_oid(oid)); match repo.find_commit(oid)?.parent_id(0) { Ok(parent) => oid = parent, Err(_) => break, @@ -289,8 +296,8 @@ fn recover_chain(repo: &git2::Repository, n_files: usize) -> anyhow::Result anyhow::Result<(Vec, Vec<(String, Vec<(usize, String)>)>)> { let candidates = josh_search::search_candidates(src, index_tree, source_tree, needle)?; @@ -312,7 +319,8 @@ impl TrigramBench { let provisioned = josh_test_support::provision_repo::provision_repo( TESTCASE, - &git2::Oid::from_str(EXPECTED_HEAD).expect("EXPECTED_HEAD must be a valid oid"), + &gix_hash::ObjectId::from_str(EXPECTED_HEAD) + .expect("EXPECTED_HEAD must be a valid oid"), |repo| { let vocab = vocabulary(); let mut heads = vec![]; @@ -344,7 +352,7 @@ impl TrigramBench { josh_core::reset_caches()?; let transaction = context.open()?; let repo = transaction.git2_repo(); - let tip_tree = repo.find_commit(tip)?.tree()?; + let tip_tree = repo.find_commit(git2_oid(tip))?.tree()?; let total_bytes = tree_content_bytes(repo, &tip_tree)?; let odb = transaction.odb(); @@ -354,7 +362,7 @@ impl TrigramBench { odb, &transaction.trigram_index_cache(tip), &mut josh_search::Indexer::default(), - tip_tree.id(), + gix_oid(tip_tree.id()), )?; transaction.flush_mem_odb()?; @@ -363,7 +371,8 @@ impl TrigramBench { // degenerated and the search numbers would be meaningless. (The bound is kept at // the pre-rework value of 5; the exact index should always produce exactly 1.) let rare_path = path_for(n_files / 2).to_string_lossy().into_owned(); - let (candidates, matches) = search(odb, index_tree_oid, tip_tree.id(), NEEDLE_RARE)?; + let (candidates, matches) = + search(odb, index_tree_oid, gix_oid(tip_tree.id()), NEEDLE_RARE)?; anyhow::ensure!( matches.len() == 1 && matches[0].0 == rare_path, "rare needle not found in exactly its planted file {rare_path}: {matches:?}" @@ -376,13 +385,13 @@ impl TrigramBench { // Gate: the common needle is found in every planted file, the absent one nowhere. let common_count = n_files.div_ceil(COMMON_EVERY); - let (_, matches) = search(odb, index_tree_oid, tip_tree.id(), NEEDLE_COMMON)?; + let (_, matches) = search(odb, index_tree_oid, gix_oid(tip_tree.id()), NEEDLE_COMMON)?; anyhow::ensure!( matches.len() == common_count, "common needle found in {} files, expected {common_count}", matches.len() ); - let (_, matches) = search(odb, index_tree_oid, tip_tree.id(), NEEDLE_ABSENT)?; + let (_, matches) = search(odb, index_tree_oid, gix_oid(tip_tree.id()), NEEDLE_ABSENT)?; anyhow::ensure!( matches.is_empty(), "absent needle found in {} files", @@ -395,23 +404,24 @@ impl TrigramBench { josh_core::reset_caches()?; let transaction = context.open()?; let repo = transaction.git2_repo(); - let root_tree = repo.find_commit(chain[0])?.tree()?; + let root_tree = repo.find_commit(git2_oid(chain[0]))?.tree()?; let odb = transaction.odb(); // One indexer state for the whole chain, matching how josh keeps one per // transaction. Collect the per-commit (source tree, index tree) pairs on the way: // the history search group iterates them. let mut indexer = josh_search::Indexer::default(); + let root_tree_oid = gix_oid(root_tree.id()); let mut chain_indexes = vec![( - root_tree.id(), + root_tree_oid, josh_search::trigram_index( odb, &transaction.trigram_index_cache(chain[0]), &mut indexer, - root_tree.id(), + root_tree_oid, )?, )]; for &oid in &chain[1..] { - let tree_oid = repo.find_commit(oid)?.tree_id(); + let tree_oid = gix_oid(repo.find_commit(git2_oid(oid))?.tree_id()); let index_oid = josh_search::trigram_index( odb, &transaction.trigram_index_cache(oid), @@ -469,7 +479,11 @@ fn trigram_benches(c: &mut Criterion) { josh_core::reset_caches().expect("reset caches"); let transaction = bench.context.open().expect("open transaction"); let repo = transaction.git2_repo(); - let tip_tree = repo.find_commit(case.tip()).expect("find tip").tree_id(); + let tip_tree = gix_oid( + repo.find_commit(git2_oid(case.tip())) + .expect("find tip") + .tree_id(), + ); let odb = transaction.odb(); let mut indexer = josh_search::Indexer::default(); @@ -502,10 +516,11 @@ fn trigram_benches(c: &mut Criterion) { josh_core::reset_caches().expect("reset caches"); let transaction = bench.context.open().expect("open transaction"); let repo = transaction.git2_repo(); - let root_tree = repo - .find_commit(case.chain[0]) - .expect("find root") - .tree_id(); + let root_tree = gix_oid( + repo.find_commit(git2_oid(case.chain[0])) + .expect("find root") + .tree_id(), + ); let odb = transaction.odb(); // One indexer state across the warm root and the whole chain, matching how // josh keeps one per transaction. @@ -520,7 +535,11 @@ fn trigram_benches(c: &mut Criterion) { runner.run(|| { for &oid in &case.chain[1..] { - let tree = repo.find_commit(oid).expect("find churn commit").tree_id(); + let tree = gix_oid( + repo.find_commit(git2_oid(oid)) + .expect("find churn commit") + .tree_id(), + ); josh_search::trigram_index( odb, &transaction.trigram_index_cache(oid), @@ -553,7 +572,11 @@ fn trigram_benches(c: &mut Criterion) { josh_core::reset_caches().expect("reset caches"); let transaction = bench.context.open().expect("open transaction"); let repo = transaction.git2_repo(); - let source_tree = repo.find_commit(case.tip()).expect("find tip").tree_id(); + let source_tree = gix_oid( + repo.find_commit(git2_oid(case.tip())) + .expect("find tip") + .tree_id(), + ); let odb = transaction.odb(); runner.run(|| { diff --git a/josh-search/src/lib.rs b/josh-search/src/lib.rs index b65e2e163..9d96b7029 100644 --- a/josh-search/src/lib.rs +++ b/josh-search/src/lib.rs @@ -42,8 +42,8 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; /// [`trigram_index`] consults this per (sub)tree, which is what makes indexing incremental: when /// a new commit is indexed, unchanged subtrees hit the cache and reuse their index. pub trait IndexCache { - fn get_index(&self, tree: git2::Oid) -> Option; - fn set_index(&self, tree: git2::Oid, index: git2::Oid); + fn get_index(&self, tree: gix_hash::ObjectId) -> Option; + fn set_index(&self, tree: gix_hash::ObjectId, index: gix_hash::ObjectId); } fn empty_tree() -> gix_hash::ObjectId { @@ -54,14 +54,6 @@ fn empty_blob() -> gix_hash::ObjectId { gix_hash::ObjectId::empty_blob(gix_hash::Kind::Sha1) } -fn to_gix(oid: git2::Oid) -> gix_hash::ObjectId { - gix_hash::ObjectId::from_bytes_or_panic(oid.as_bytes()) -} - -fn to_git2(oid: gix_hash::ObjectId) -> git2::Oid { - git2::Oid::from_bytes(oid.as_bytes()).expect("oid size mismatch") -} - /// Fold a byte for trigram extraction: ASCII letters lowercase, and every ASCII byte that is /// not alphanumeric or `_` (whitespace, punctuation, brackets, operators) becomes one class /// glyph. Folding collapses the combinatorial variety of near-content-free trigrams — on @@ -137,19 +129,19 @@ pub struct Indexer { /// Parsed trees from `pending` and the ODB, so merges don't re-parse the same spine nodes. trees: HashMap>, /// Source tree -> index, for trees indexed or cache-resolved with this state. - tree_memo: HashMap, + tree_memo: HashMap, /// Source blob and entry name -> the file's wrapped trigram tree. The name is part of the /// key because the mirror entries inside carry it. - blob_memo: HashMap<(git2::Oid, String), gix_hash::ObjectId>, + blob_memo: HashMap<(gix_hash::ObjectId, String), gix_hash::ObjectId>, /// Source blob -> its nameless trigram spine (empty-blob leaves), the building block of /// coarse indexes. Name-independent, so identical blobs share it everywhere. - blob_spine_memo: HashMap, + blob_spine_memo: HashMap, /// Source tree -> its coarse index (the subtree treated as one pseudo-file). Kept apart /// from the fine-grained [`IndexCache`]; coarse subtrees are small by definition, so /// keeping this per process is cheap enough. - coarse_memo: HashMap, + coarse_memo: HashMap, /// Source tree -> transitive `(file count, content bytes)`, for the granularity decision. - stats_memo: HashMap, + stats_memo: HashMap, wrap_memo: HashMap<(gix_hash::ObjectId, String), gix_hash::ObjectId>, overlay_memo: HashMap, gix_hash::ObjectId>, } @@ -178,7 +170,7 @@ struct Run<'a> { ix: &'a mut Indexer, /// `(source tree, index)` pairs of this call, memoized by [`flush`](Run::flush) only after /// their objects reach the ODB: an [`IndexCache`] entry must never point at missing objects. - roots: Vec<(git2::Oid, gix_hash::ObjectId)>, + roots: Vec<(gix_hash::ObjectId, gix_hash::ObjectId)>, } impl Run<'_> { @@ -273,7 +265,12 @@ impl Run<'_> { /// `{name: empty blob}` at every leaf. Building the wrapped form directly (rather than /// wrapping afterwards via [`wrap`](Run::wrap)) avoids an intermediate tree that would /// be rewritten anyway. - fn index_blob(&mut self, oid: git2::Oid, name: &str, content: &str) -> gix_hash::ObjectId { + fn index_blob( + &mut self, + oid: gix_hash::ObjectId, + name: &str, + content: &str, + ) -> gix_hash::ObjectId { let key = (oid, name.to_owned()); if let Some(id) = self.ix.blob_memo.get(&key) { return *id; @@ -292,7 +289,7 @@ impl Run<'_> { /// The nameless trigram spine of one blob: empty-blob leaves, no mirror. The building /// block of coarse (directory granularity) indexes. - fn blob_spine(&mut self, oid: git2::Oid, content: &str) -> gix_hash::ObjectId { + fn blob_spine(&mut self, oid: gix_hash::ObjectId, content: &str) -> gix_hash::ObjectId { if let Some(id) = self.ix.blob_spine_memo.get(&oid) { return *id; } @@ -306,15 +303,15 @@ impl Run<'_> { } /// Transitive `(file count, content bytes)` of a tree, for the granularity decision. - fn subtree_stats(&mut self, tree_oid: git2::Oid) -> anyhow::Result<(u64, u64)> { + fn subtree_stats(&mut self, tree_oid: gix_hash::ObjectId) -> anyhow::Result<(u64, u64)> { if let Some(stats) = self.ix.stats_memo.get(&tree_oid) { return Ok(*stats); } - let tree = self.read_tree(to_gix(tree_oid))?; + let tree = self.read_tree(tree_oid)?; let (mut files, mut bytes) = (0u64, 0u64); for entry in tree.entries.clone() { if entry.mode.is_tree() { - let (f, b) = self.subtree_stats(to_git2(entry.oid))?; + let (f, b) = self.subtree_stats(entry.oid.to_owned())?; files += f; bytes += b; } else if !entry.mode.is_commit() { @@ -332,7 +329,7 @@ impl Run<'_> { } /// Whether a child directory is recorded at directory granularity by its parent. - fn is_small(&mut self, tree_oid: git2::Oid) -> anyhow::Result { + fn is_small(&mut self, tree_oid: gix_hash::ObjectId) -> anyhow::Result { let (files, bytes) = self.subtree_stats(tree_oid)?; Ok(files <= COARSE_MAX_FILES && bytes <= COARSE_MAX_BYTES) } @@ -342,19 +339,19 @@ impl Run<'_> { /// structure. Wrapping it under the directory's name yields `{name: empty blob}` mirrors: /// a blob leaf where the source has a directory, meaning "some file under here contains /// the trigram". - fn coarse_index(&mut self, tree_oid: git2::Oid) -> anyhow::Result { + fn coarse_index(&mut self, tree_oid: gix_hash::ObjectId) -> anyhow::Result { if let Some(id) = self.ix.coarse_memo.get(&tree_oid) { return Ok(*id); } - let tree = self.read_tree(to_gix(tree_oid))?; + let tree = self.read_tree(tree_oid)?; let mut spines = Vec::with_capacity(tree.entries.len()); for entry in tree.entries.clone() { if entry.mode.is_tree() { - spines.push(self.coarse_index(to_git2(entry.oid))?); + spines.push(self.coarse_index(entry.oid.to_owned())?); } else if !entry.mode.is_commit() { let content = read_blob_text(self.src, entry.oid); - spines.push(self.blob_spine(to_git2(entry.oid), &content)); + spines.push(self.blob_spine(entry.oid.to_owned(), &content)); } } let index = self.overlay_many(spines)?; @@ -473,13 +470,16 @@ impl Run<'_> { /// The index of a directory: the overlay of its children's wrapped indexes, memoized per /// source tree oid. The root is not special — incrementality is just this memo hitting on /// unchanged subtrees. - fn index_tree_oid(&mut self, tree_oid: git2::Oid) -> anyhow::Result { - let tree = self.read_tree(to_gix(tree_oid))?; + fn index_tree_oid( + &mut self, + tree_oid: gix_hash::ObjectId, + ) -> anyhow::Result { + let tree = self.read_tree(tree_oid)?; if let Some(id) = self.ix.tree_memo.get(&tree_oid) { return Ok(*id); } if let Some(cached) = self.cache.get_index(tree_oid) { - let id = to_gix(cached); + let id = cached; self.ix.tree_memo.insert(tree_oid, id); return Ok(id); } @@ -487,7 +487,7 @@ impl Run<'_> { let mut wrapped = Vec::with_capacity(tree.entries.len()); for entry in tree.entries.clone() { let name = std::str::from_utf8(&entry.filename)?.to_owned(); - let child_oid = to_git2(entry.oid); + let child_oid = entry.oid.to_owned(); if entry.mode.is_tree() { // Small directories are recorded at directory granularity: one coarse // leaf for the whole directory instead of per-file mirrors. @@ -559,7 +559,7 @@ impl Run<'_> { } for (tree, index) in &self.roots { - self.cache.set_index(*tree, to_git2(*index)); + self.cache.set_index(*tree, *index); } Ok(()) } @@ -569,8 +569,8 @@ pub fn trigram_index( src: &dyn Objects, cache: &dyn IndexCache, indexer: &mut Indexer, - tree: git2::Oid, -) -> anyhow::Result { + tree: gix_hash::ObjectId, +) -> anyhow::Result { let mut run = Run { src, cache, @@ -579,7 +579,7 @@ pub fn trigram_index( }; let index = run.index_tree_oid(tree)?; run.flush()?; - Ok(to_git2(index)) + Ok(index) } /// The candidate files for `searchstring`: those containing every trigram of the query. @@ -588,8 +588,8 @@ pub fn trigram_index( /// candidate then, and [`search_matches`] does the filtering. pub fn search_candidates( src: &dyn Objects, - index_tree: git2::Oid, - source_tree: git2::Oid, + index_tree: gix_hash::ObjectId, + source_tree: gix_hash::ObjectId, searchstring: &str, ) -> anyhow::Result> { let trigrams = distinct_trigrams(searchstring); @@ -638,8 +638,8 @@ pub fn search_candidates( /// expands to every file under that source directory. fn intersect_walk( src: &dyn Objects, - roots: &[git2::Oid], - source: git2::Oid, + roots: &[gix_hash::ObjectId], + source: gix_hash::ObjectId, prefix: &str, out: &mut Vec, ) -> anyhow::Result<()> { @@ -666,7 +666,7 @@ fn intersect_walk( for tree in &trees { match tree.iter().find(|e| e.filename == entry.filename) { Some(other) if other.mode.is_tree() == entry.mode.is_tree() => { - child_roots.push(to_git2(other.oid)) + child_roots.push(other.oid.to_owned()) } _ => continue 'entry, } @@ -678,7 +678,7 @@ fn intersect_walk( else { continue; }; - intersect_walk(src, &child_roots, to_git2(source_entry.oid), &path, out)?; + intersect_walk(src, &child_roots, source_entry.oid.to_owned(), &path, out)?; } else if !entry.mode.is_commit() { emit_leaf(src, &source_entries, name, &path, out)?; } @@ -699,7 +699,7 @@ fn emit_leaf( .iter() .find(|e| e.filename == name.as_bytes()) { - Some(e) if e.mode.is_tree() => collect_paths(src, to_git2(e.oid), path, out)?, + Some(e) if e.mode.is_tree() => collect_paths(src, e.oid.to_owned(), path, out)?, Some(e) if !e.mode.is_commit() => out.push(path.to_owned()), _ => {} } @@ -709,8 +709,8 @@ fn emit_leaf( /// Emit every candidate of the single mirror `oid`, following `source` for coarse expansion. fn collect_mirror_paths( src: &dyn Objects, - oid: git2::Oid, - source: git2::Oid, + oid: gix_hash::ObjectId, + source: gix_hash::ObjectId, prefix: &str, out: &mut Vec, ) -> anyhow::Result<()> { @@ -725,8 +725,8 @@ fn collect_mirror_paths( }; collect_mirror_paths( src, - to_git2(entry.oid), - to_git2(source_entry.oid), + entry.oid.to_owned(), + source_entry.oid.to_owned(), &path, out, )?; @@ -740,11 +740,11 @@ fn collect_mirror_paths( /// The entries of the tree `oid`, owned so several trees can be walked side by side. fn read_tree_entries( src: &dyn Objects, - oid: git2::Oid, + oid: gix_hash::ObjectId, ) -> anyhow::Result> { let mut buffer = Vec::new(); let data = src - .try_find(&to_gix(oid), &mut buffer) + .try_find(&oid, &mut buffer) .map_err(|e| anyhow::anyhow!("read tree {}: {}", oid, e))? .ok_or_else(|| anyhow::anyhow!("object {} not found", oid))?; if data.kind != gix_object::Kind::Tree { @@ -760,13 +760,13 @@ fn read_tree_entries( /// Emit every blob path under `oid` (a tree), prefixed with `prefix`. fn collect_paths( src: &dyn Objects, - oid: git2::Oid, + oid: gix_hash::ObjectId, prefix: &str, out: &mut Vec, ) -> anyhow::Result<()> { let mut buffer = Vec::new(); let Some(data) = src - .try_find(&to_gix(oid), &mut buffer) + .try_find(&oid, &mut buffer) .map_err(|e| anyhow::anyhow!("read tree {}: {}", oid, e))? else { return Ok(()); @@ -779,7 +779,7 @@ fn collect_paths( let name = std::str::from_utf8(&entry.filename)?; let path = join_path(prefix, name); if entry.mode.is_tree() { - collect_paths(src, to_git2(entry.oid), &path, out)?; + collect_paths(src, entry.oid.to_owned(), &path, out)?; } else if !entry.mode.is_commit() { out.push(path); } @@ -799,7 +799,7 @@ type SearchMatchesResult = Vec<(String, Vec<(usize, String)>)>; pub fn search_matches( src: &dyn Objects, - tree: git2::Oid, + tree: gix_hash::ObjectId, searchstring: &str, candidates: &Vec, ) -> anyhow::Result { @@ -825,9 +825,9 @@ pub fn search_matches( } /// Like [`read_blob_text`], but for a path inside `tree`. -fn get_blob_path(src: &dyn Objects, tree: git2::Oid, path: &std::path::Path) -> String { +fn get_blob_path(src: &dyn Objects, tree: gix_hash::ObjectId, path: &std::path::Path) -> String { match path_entry(src, tree, path) { - Ok(Some(oid)) => read_blob_text(src, to_gix(oid)), + Ok(Some(oid)) => read_blob_text(src, oid), _ => "".to_owned(), } } @@ -835,15 +835,15 @@ fn get_blob_path(src: &dyn Objects, tree: git2::Oid, path: &std::path::Path) -> /// The oid at `path` inside `tree`, or `None` when any component is missing or not a tree. fn path_entry( src: &dyn Objects, - tree: git2::Oid, + tree: gix_hash::ObjectId, path: &std::path::Path, -) -> anyhow::Result> { +) -> anyhow::Result> { let mut current = tree; let mut components = path.components().peekable(); while let Some(component) = components.next() { let mut buffer = Vec::new(); let Some(data) = src - .try_find(&to_gix(current), &mut buffer) + .try_find(¤t, &mut buffer) .map_err(|e| anyhow::anyhow!("read tree {}: {}", current, e))? else { return Ok(None); @@ -852,11 +852,11 @@ fn path_entry( return Ok(None); } let parsed = gix_object::TreeRef::from_bytes(&buffer, gix_hash::Kind::Sha1)?; - let name = std::os::unix::ffi::OsStrExt::as_bytes(component.as_os_str()); + let name = josh_gix_ext::component_bytes(component.as_os_str()); let Some(entry) = parsed.entries.iter().find(|e| e.filename == name) else { return Ok(None); }; - current = to_git2(entry.oid.to_owned()); + current = entry.oid.to_owned(); if components.peek().is_some() && !entry.mode.is_tree() { return Ok(None); } @@ -867,6 +867,13 @@ fn path_entry( #[cfg(test)] mod tests { use super::*; + fn gix(oid: git2::Oid) -> gix_hash::ObjectId { + gix_hash::ObjectId::from_bytes_or_panic(oid.as_bytes()) + } + + fn git2(oid: gix_hash::ObjectId) -> git2::Oid { + git2::Oid::from_bytes(oid.as_bytes()).expect("SHA-1 object id") + } #[test] fn distinct_trigrams_basics() { @@ -896,14 +903,14 @@ mod tests { #[derive(Default)] struct MapCache { - map: std::cell::RefCell>, + map: std::cell::RefCell>, } impl IndexCache for MapCache { - fn get_index(&self, tree: git2::Oid) -> Option { + fn get_index(&self, tree: gix_hash::ObjectId) -> Option { self.map.borrow().get(&tree).copied() } - fn set_index(&self, tree: git2::Oid, index: git2::Oid) { + fn set_index(&self, tree: gix_hash::ObjectId, index: gix_hash::ObjectId) { self.map.borrow_mut().insert(tree, index); } } @@ -946,8 +953,13 @@ mod tests { ], ); - let index = - trigram_index(&objects(&repo), &cache, &mut Indexer::default(), tree.id()).unwrap(); + let index = trigram_index( + &objects(&repo), + &cache, + &mut Indexer::default(), + gix(tree.id()), + ) + .unwrap(); // The index format is pinned: cached indexes of older josh versions stay valid only as // long as this oid does not change. @@ -966,35 +978,42 @@ mod tests { .unwrap() .unwrap(); assert_eq!( - repo.find_object(leaf, None).unwrap().kind(), + repo.find_object(git2(leaf), None).unwrap().kind(), Some(git2::ObjectType::Blob) ); // Coarse hits expand to every file under the directory; verification is exact. - let candidates = search_candidates(&objects(&repo), index, tree.id(), "document").unwrap(); + let candidates = + search_candidates(&objects(&repo), index, gix(tree.id()), "document").unwrap(); assert_eq!(candidates, vec!["sub1/file1", "sub1/file2"]); - let matches = search_matches(&objects(&repo), tree.id(), "document", &candidates).unwrap(); + let matches = + search_matches(&objects(&repo), gix(tree.id()), "document", &candidates).unwrap(); assert_eq!(matches.len(), 2); // Trigrams are case-folded, so candidates are a case-insensitive superset ("Test" in // file1 makes sub1 a candidate for "test") while match verification stays byte-exact. - let candidates = search_candidates(&objects(&repo), index, tree.id(), "test").unwrap(); + let candidates = search_candidates(&objects(&repo), index, gix(tree.id()), "test").unwrap(); assert_eq!(candidates, vec!["sub1/file1", "sub1/file2"]); - let matches = search_matches(&objects(&repo), tree.id(), "test", &candidates).unwrap(); + let matches = search_matches(&objects(&repo), gix(tree.id()), "test", &candidates).unwrap(); assert!(matches.is_empty()); let candidates = - search_candidates(&objects(&repo), index, tree.id(), "missingword").unwrap(); + search_candidates(&objects(&repo), index, gix(tree.id()), "missingword").unwrap(); assert!(candidates.is_empty()); // Short query: every file is a candidate. - let candidates = search_candidates(&objects(&repo), index, tree.id(), "e").unwrap(); + let candidates = search_candidates(&objects(&repo), index, gix(tree.id()), "e").unwrap(); assert_eq!(candidates.len(), 3); // Indexing is deterministic and memoization-independent. let cold = MapCache::default(); - let index2 = - trigram_index(&objects(&repo), &cold, &mut Indexer::default(), tree.id()).unwrap(); + let index2 = trigram_index( + &objects(&repo), + &cold, + &mut Indexer::default(), + gix(tree.id()), + ) + .unwrap(); assert_eq!(index, index2); } @@ -1018,8 +1037,13 @@ mod tests { let files: Vec<(&str, &str)> = files.iter().map(|(p, c)| (&p[..], &c[..])).collect(); let tree = commit_tree(&repo, &files); - let index = - trigram_index(&objects(&repo), &cache, &mut Indexer::default(), tree.id()).unwrap(); + let index = trigram_index( + &objects(&repo), + &cache, + &mut Indexer::default(), + gix(tree.id()), + ) + .unwrap(); // Fine: "d07" (from uniqueword07) mirrors big's structure down to the file. let leaf = path_entry( @@ -1030,7 +1054,7 @@ mod tests { .unwrap() .unwrap(); assert_eq!( - repo.find_object(leaf, None).unwrap().kind(), + repo.find_object(git2(leaf), None).unwrap().kind(), Some(git2::ObjectType::Blob) ); @@ -1043,29 +1067,39 @@ mod tests { .unwrap() .unwrap(); assert_eq!( - repo.find_object(leaf, None).unwrap().kind(), + repo.find_object(git2(leaf), None).unwrap().kind(), Some(git2::ObjectType::Blob) ); // Fine candidates stay per-file and exact. let candidates = - search_candidates(&objects(&repo), index, tree.id(), "uniqueword07").unwrap(); + search_candidates(&objects(&repo), index, gix(tree.id()), "uniqueword07").unwrap(); assert_eq!(candidates, vec!["big/file_07"]); // A coarse hit makes every file under the directory a candidate; verification is // exact. let candidates = - search_candidates(&objects(&repo), index, tree.id(), "needleinsmall").unwrap(); + search_candidates(&objects(&repo), index, gix(tree.id()), "needleinsmall").unwrap(); assert_eq!(candidates, vec!["small/a", "small/b"]); - let matches = - search_matches(&objects(&repo), tree.id(), "needleinsmall", &candidates).unwrap(); + let matches = search_matches( + &objects(&repo), + gix(tree.id()), + "needleinsmall", + &candidates, + ) + .unwrap(); assert_eq!(matches.len(), 1); assert_eq!(matches[0].0, "small/a"); // Determinism across memoization states, coarse dirs included. let cold = MapCache::default(); - let index2 = - trigram_index(&objects(&repo), &cold, &mut Indexer::default(), tree.id()).unwrap(); + let index2 = trigram_index( + &objects(&repo), + &cold, + &mut Indexer::default(), + gix(tree.id()), + ) + .unwrap(); assert_eq!(index, index2); } @@ -1086,7 +1120,7 @@ mod tests { ("sub2/mod", "alpha beta gamma"), ], ); - trigram_index(&objects(&repo), &cache, &mut indexer, tree_a.id()).unwrap(); + trigram_index(&objects(&repo), &cache, &mut indexer, gix(tree_a.id())).unwrap(); // One file modified, one removed (its unique trigrams must vanish from the spine), one // added in a fresh directory. @@ -1098,25 +1132,32 @@ mod tests { ("sub3/new", "fresh addition here"), ], ); - let index_b = trigram_index(&objects(&repo), &cache, &mut indexer, tree_b.id()).unwrap(); + let index_b = + trigram_index(&objects(&repo), &cache, &mut indexer, gix(tree_b.id())).unwrap(); // The roots are memoized in the persistent cache. (The sub dirs are below the coarse // threshold and live in the Indexer's coarse memo instead — only fine-grained indexes // go through the IndexCache.) - assert!(cache.get_index(tree_b.id()).is_some()); + assert!(cache.get_index(gix(tree_b.id())).is_some()); // Warm (incremental) and cold-built indexes agree bit for bit. let cold = MapCache::default(); - let index_b_cold = - trigram_index(&objects(&repo), &cold, &mut Indexer::default(), tree_b.id()).unwrap(); + let index_b_cold = trigram_index( + &objects(&repo), + &cold, + &mut Indexer::default(), + gix(tree_b.id()), + ) + .unwrap(); assert_eq!(index_b, index_b_cold); // The incremental index searches correctly. - let hits = search_candidates(&objects(&repo), index_b, tree_b.id(), "delta").unwrap(); + let hits = search_candidates(&objects(&repo), index_b, gix(tree_b.id()), "delta").unwrap(); assert_eq!(hits, vec!["sub2/mod"]); - let hits = search_candidates(&objects(&repo), index_b, tree_b.id(), "zebra").unwrap(); + let hits = search_candidates(&objects(&repo), index_b, gix(tree_b.id()), "zebra").unwrap(); assert!(hits.is_empty()); - let hits = search_candidates(&objects(&repo), index_b, tree_b.id(), "addition").unwrap(); + let hits = + search_candidates(&objects(&repo), index_b, gix(tree_b.id()), "addition").unwrap(); assert_eq!(hits, vec!["sub3/new"]); } } diff --git a/josh-starlark/Cargo.toml b/josh-starlark/Cargo.toml index 81d351f1b..dcec16daf 100644 --- a/josh-starlark/Cargo.toml +++ b/josh-starlark/Cargo.toml @@ -14,10 +14,13 @@ starlark = "0.14.2" allocative = "0.3" anyhow.workspace = true -git2.workspace = true josh-filter.workspace = true josh-gix-ext.workspace = true +gix-hash.workspace = true gix-object.workspace = true +[dev-dependencies] +git2.workspace = true + diff --git a/josh-starlark/src/evaluate.rs b/josh-starlark/src/evaluate.rs index 04c64c9ef..77a66f8e2 100644 --- a/josh-starlark/src/evaluate.rs +++ b/josh-starlark/src/evaluate.rs @@ -23,7 +23,7 @@ use starlark::{ /// The evaluation is synchronous; no threads are spawned and no values escape. pub fn evaluate( script: &str, - tree_oid: git2::Oid, + tree_oid: gix_hash::ObjectId, objects: &dyn gix_object::Find, ) -> anyhow::Result { // Parse the starlark script diff --git a/josh-starlark/src/filter.rs b/josh-starlark/src/filter.rs index 539018e69..269ce1827 100644 --- a/josh-starlark/src/filter.rs +++ b/josh-starlark/src/filter.rs @@ -8,19 +8,15 @@ use starlark::{ use std::fmt::{self, Display}; use std::path::PathBuf; -/// Opaque Filter type for Starlark -/// We wrap Filter in a newtype that implements the required traits +/// Opaque Starlark filter. #[derive(Debug, Clone, Copy, ProvidesStaticType, NoSerialize)] pub struct StarlarkFilter { pub filter: Filter, } -// Implement Allocative manually since Filter doesn't implement it -// Filter is just a wrapper around git2::Oid which is Copy and small +// Filter owns no heap allocations. impl Allocative for StarlarkFilter { - fn visit<'a, 'b: 'a>(&self, _visitor: &'a mut allocative::Visitor<'b>) { - // Filter contains only a git2::Oid which is Copy and doesn't need visiting - } + fn visit<'a, 'b: 'a>(&self, _visitor: &'a mut allocative::Visitor<'b>) {} } starlark_simple_value!(StarlarkFilter); diff --git a/josh-starlark/src/tests.rs b/josh-starlark/src/tests.rs index 6087fd024..9aafb0c9f 100644 --- a/josh-starlark/src/tests.rs +++ b/josh-starlark/src/tests.rs @@ -1,12 +1,13 @@ use crate::evaluate::evaluate; use josh_filter::spec; +use std::str::FromStr; #[test] fn test_simple_filter() -> anyhow::Result<()> { let temp_dir = std::env::temp_dir().join("josh_starlark_test"); let _ = std::fs::remove_dir_all(&temp_dir); let repo = git2::Repository::init(&temp_dir)?; - let empty_tree_oid = git2::Oid::from_str("4b825dc642cb6eb9a060e54bf8d69288fbee4904")?; + let empty_tree_oid = gix_hash::ObjectId::from_str("4b825dc642cb6eb9a060e54bf8d69288fbee4904")?; let script = r#" filter = filter.subdir("src") @@ -23,7 +24,7 @@ fn test_chain_filter() -> anyhow::Result<()> { let temp_dir = std::env::temp_dir().join("josh_starlark_test2"); let _ = std::fs::remove_dir_all(&temp_dir); let repo = git2::Repository::init(&temp_dir)?; - let empty_tree_oid = git2::Oid::from_str("4b825dc642cb6eb9a060e54bf8d69288fbee4904")?; + let empty_tree_oid = gix_hash::ObjectId::from_str("4b825dc642cb6eb9a060e54bf8d69288fbee4904")?; let script = r#" filter = filter.subdir("src").prefix("lib") @@ -40,7 +41,7 @@ fn test_file_filter() -> anyhow::Result<()> { let temp_dir = std::env::temp_dir().join("josh_starlark_test3"); let _ = std::fs::remove_dir_all(&temp_dir); let repo = git2::Repository::init(&temp_dir)?; - let empty_tree_oid = git2::Oid::from_str("4b825dc642cb6eb9a060e54bf8d69288fbee4904")?; + let empty_tree_oid = gix_hash::ObjectId::from_str("4b825dc642cb6eb9a060e54bf8d69288fbee4904")?; let script = r#" filter = filter.file("README.md") @@ -58,7 +59,7 @@ fn test_compose() -> anyhow::Result<()> { let temp_dir = std::env::temp_dir().join("josh_starlark_test4"); let _ = std::fs::remove_dir_all(&temp_dir); let repo = git2::Repository::init(&temp_dir)?; - let empty_tree_oid = git2::Oid::from_str("4b825dc642cb6eb9a060e54bf8d69288fbee4904")?; + let empty_tree_oid = gix_hash::ObjectId::from_str("4b825dc642cb6eb9a060e54bf8d69288fbee4904")?; let script = r#" f1 = filter.subdir("src") @@ -74,7 +75,7 @@ filter = compose([f1, f2]) } // Helper function to create a test repository with files and directories -fn create_test_repo() -> anyhow::Result<(git2::Repository, git2::Oid)> { +fn create_test_repo() -> anyhow::Result<(git2::Repository, gix_hash::ObjectId)> { // Process id and a counter keep the directory unique across parallel tests; the // timestamp alone collides when two tests read the clock in the same tick. static DIR_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); @@ -119,7 +120,7 @@ fn create_test_repo() -> anyhow::Result<(git2::Repository, git2::Oid)> { root_builder.write()? }; - Ok((repo, root_tree_oid)) + Ok((repo, josh_gix_ext::gix_oid(root_tree_oid))) } #[test] diff --git a/josh-starlark/src/tree.rs b/josh-starlark/src/tree.rs index ae204e436..a3615d312 100644 --- a/josh-starlark/src/tree.rs +++ b/josh-starlark/src/tree.rs @@ -13,7 +13,7 @@ use std::path::PathBuf; /// We wrap a git tree by storing its OID and a raw pointer to the object source it came from. #[derive(Clone, ProvidesStaticType, NoSerialize)] pub(crate) struct StarlarkTree { - pub tree_oid: git2::Oid, + pub tree_oid: gix_hash::ObjectId, // SAFETY: StarlarkTree is only constructed inside `evaluate()`, which is // synchronous and spawns no threads. The referenced object source must // remain alive and at a stable address for that entire duration so this raw @@ -68,7 +68,7 @@ impl StarlarkTree { /// construct `StarlarkTree` values in contexts that guarantee the object source /// outlives the tree and all of its clones, such as the synchronous /// `evaluate()` flow described in the struct-level safety comments. - pub(crate) fn new(tree_oid: git2::Oid, objects: &dyn gix_object::Find) -> Self { + pub(crate) fn new(tree_oid: gix_hash::ObjectId, objects: &dyn gix_object::Find) -> Self { let objects: *const (dyn gix_object::Find + '_) = objects; Self { tree_oid, @@ -85,12 +85,12 @@ impl StarlarkTree { } /// Get empty tree OID - fn empty_tree_oid() -> git2::Oid { - git2::Oid::from_str("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap() + fn empty_tree_oid() -> gix_hash::ObjectId { + gix_hash::ObjectId::empty_tree(gix_hash::Kind::Sha1) } /// Navigate to a path in the tree, returning the OID of the tree at that path - fn navigate_to_path_oid(&self, path: &str) -> anyhow::Result { + fn navigate_to_path_oid(&self, path: &str) -> anyhow::Result { if path.is_empty() { return Ok(self.tree_oid); } @@ -113,7 +113,7 @@ impl StarlarkTree { return Err(anyhow!("Path component '{}' is not a directory", component)); } - current_tree_oid = josh_gix_ext::git2_oid(&entry.oid); + current_tree_oid = entry.oid; } Ok(current_tree_oid) @@ -130,7 +130,7 @@ impl StarlarkTree { if !entry.mode.is_blob() { return String::new(); } - josh_gix_ext::blob_text(objects, josh_gix_ext::git2_oid(&entry.oid)) + josh_gix_ext::blob_text(objects, entry.oid) } /// The full paths of the entries at `path` that satisfy `keep`, in stored tree order. diff --git a/josh-templates/Cargo.toml b/josh-templates/Cargo.toml index 0399b9b48..6793c5ab7 100644 --- a/josh-templates/Cargo.toml +++ b/josh-templates/Cargo.toml @@ -13,7 +13,7 @@ handlebars = "6.4.3" form_urlencoded.workspace = true anyhow.workspace = true -git2.workspace = true +gix-hash.workspace = true juniper.workspace = true serde_json.workspace = true diff --git a/josh-templates/src/templates.rs b/josh-templates/src/templates.rs index d4715601e..9e83b1517 100644 --- a/josh-templates/src/templates.rs +++ b/josh-templates/src/templates.rs @@ -7,7 +7,7 @@ struct GraphQLHelper { repo_path: std::path::PathBuf, cache: std::sync::Arc, ref_prefix: String, - commit_id: git2::Oid, + commit_id: gix_hash::ObjectId, } impl GraphQLHelper { @@ -53,7 +53,7 @@ impl GraphQLHelper { let tree = josh_core::objects::CommitData::read(odb, self.commit_id)?.tree_id()?; let entry = josh_core::objects::path_entry(odb, tree, &path)? .ok_or_else(|| anyhow!("no such path: {}", path.display()))?; - let query = josh_core::objects::blob_text(odb, josh_core::objects::git2_oid(&entry.oid)); + let query = josh_core::objects::blob_text(odb, entry.oid.to_owned()); let mut variables = juniper::Variables::new(); @@ -125,7 +125,7 @@ pub fn render( transaction: &cache::Transaction, cache: std::sync::Arc, ref_prefix: &str, - commit_id: git2::Oid, + commit_id: gix_hash::ObjectId, query_and_params: &str, split_odb: bool, ) -> anyhow::Result)>> { @@ -160,7 +160,7 @@ pub fn render( }; let template = if entry.mode.is_blob() { - let content = josh_core::objects::blob_text(odb, josh_core::objects::git2_oid(&entry.oid)); + let content = josh_core::objects::blob_text(odb, entry.oid.to_owned()); let file = content.as_str(); if cmd == "get" { return Ok(Some((file.to_string(), params))); diff --git a/tests/windows/cli.sh b/tests/windows/cli.sh new file mode 100755 index 000000000..9c9f8000e --- /dev/null +++ b/tests/windows/cli.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Functional test for the josh CLI on a platform where `josh compose` cannot +# run. Exercises filtering, cloning, pulling and pushing against a local bare +# repository: no server, no network, nothing but git. +# +# tests/windows/cli.sh +# +# The clone deliberately targets a relative directory, which is what turns a +# path into a remote URL internally — the case that was broken on Windows. +set -euo pipefail + +BIN_DIR="$(cd "$1" && pwd)" +EXE="" +[ -f "$BIN_DIR/josh.exe" ] && EXE=".exe" +JOSH="$BIN_DIR/josh$EXE" +JOSH_FILTER="$BIN_DIR/josh-filter$EXE" +[ -f "$JOSH" ] || { echo "FAIL: $JOSH not found" >&2; exit 1; } +[ -f "$JOSH_FILTER" ] || { echo "FAIL: $JOSH_FILTER not found" >&2; exit 1; } + +WORK="$(mktemp -d)" +BRANCH="main" +fail() { echo "FAIL: $*" >&2; exit 1; } + +echo "== setup: local upstream" +git init -q --bare -b "$BRANCH" "$WORK/upstream.git" +git init -q -b "$BRANCH" "$WORK/seed" +git -C "$WORK/seed" config user.email t@t +git -C "$WORK/seed" config user.name t +echo hello > "$WORK/seed/README.md" +git -C "$WORK/seed" add . && git -C "$WORK/seed" commit -qm "c1: readme" +mkdir -p "$WORK/seed/src" && echo lib > "$WORK/seed/src/lib.txt" +git -C "$WORK/seed" add . && git -C "$WORK/seed" commit -qm "c2: lib" +git -C "$WORK/seed" push -q "$WORK/upstream.git" "$BRANCH" + +echo "== josh-filter" +git clone -q "$WORK/upstream.git" "$WORK/plain" +(cd "$WORK/plain" && "$JOSH_FILTER" ":prefix=lib" "$BRANCH") || fail "josh-filter" +git -C "$WORK/plain" ls-tree --name-only -r FILTERED_HEAD | grep -qx "lib/README.md" \ + || fail "josh-filter: prefix missing from FILTERED_HEAD" +[ "$(git -C "$WORK/plain" rev-list --count FILTERED_HEAD)" = 2 ] \ + || fail "josh-filter: expected 2 commits" + +echo "== josh clone, into a relative directory" +mkdir -p "$WORK/cli" && cd "$WORK/cli" +"$JOSH" clone "$WORK/upstream.git" ":prefix=lib" ./clone || fail "josh clone" +[ -f "$WORK/cli/clone/lib/README.md" ] || fail "josh clone: prefix missing" +[ "$(git -C "$WORK/cli/clone" rev-list --count HEAD)" = 2 ] \ + || fail "josh clone: expected 2 commits" + +echo "== josh changes pull" +echo more >> "$WORK/seed/src/lib.txt" +git -C "$WORK/seed" commit -qam "c3: more lib" +C3="$(git -C "$WORK/seed" rev-parse HEAD)" +git -C "$WORK/seed" push -q "$WORK/upstream.git" "$BRANCH" +(cd "$WORK/cli/clone" && "$JOSH" changes pull) || fail "josh changes pull" +grep -q more "$WORK/cli/clone/lib/src/lib.txt" \ + || fail "josh changes pull: upstream change did not arrive through the filter" + +echo "== josh push" +cd "$WORK/cli/clone" +git config user.email t@t +git config user.name t +echo change >> lib/src/lib.txt +git commit -qam "c4: change through the filter" +"$JOSH" push origin "HEAD:refs/heads/roundtrip" --base "$BRANCH" || fail "josh push" +RT="$(git -C "$WORK/upstream.git" rev-parse refs/heads/roundtrip)" \ + || fail "josh push: branch missing upstream" +git -C "$WORK/upstream.git" show "$RT:src/lib.txt" | grep -q change \ + || fail "josh push: change not reverse-filtered to src/lib.txt" +[ "$(git -C "$WORK/upstream.git" rev-parse "$RT^")" = "$C3" ] \ + || fail "josh push: pushed commit is not rooted on the upstream tip" + +echo "PASS" diff --git a/tests/windows/proxy.sh b/tests/windows/proxy.sh new file mode 100755 index 000000000..ec876375e --- /dev/null +++ b/tests/windows/proxy.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Functional test for josh-proxy on a platform where `josh compose` cannot run. +# Drives a real proxy against a real upstream: filtered clone, pinned-SHA fetch, +# reverse-filter push, and reuse of the --local cache across a restart. +# +# UPSTREAM_URL=http://127.0.0.1:8177 tests/windows/proxy.sh [cache-dir] +# +# UPSTREAM_URL is the base URL of a git server exporting the repositories in +# UPSTREAM_ROOT (tests/windows/serve-git.ps1 provides one on Windows). The +# optional cache directory lets a caller exercise unusual path forms. +set -euo pipefail + +JOSH_PROXY="$1" +UPSTREAM_ROOT="${UPSTREAM_ROOT:?set UPSTREAM_ROOT to the served directory}" +UPSTREAM_URL="${UPSTREAM_URL:?set UPSTREAM_URL to the serving base URL}" +WORK="$(mktemp -d)" +LOCAL_DIR="${2:-$WORK/local}" +PORT="${JOSH_PORT:-42190}" +BRANCH="main" + +JOSH_PID="" +cleanup() { [ -n "$JOSH_PID" ] && kill "$JOSH_PID" 2>/dev/null || true; } +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + [ -s "$WORK/josh.log" ] && { echo "--- josh-proxy log:" >&2; cat "$WORK/josh.log" >&2; } + exit 1 +} + +start_proxy() { + "$JOSH_PROXY" --local "$LOCAL_DIR" --remote "$UPSTREAM_URL" \ + "--port=$PORT" --no-background >>"$WORK/josh.log" 2>&1 & + JOSH_PID=$! + for _ in $(seq 1 100); do + curl -s -o /dev/null --max-time 1 "http://127.0.0.1:$PORT/" && return 0 + [ "$?" -ne 7 ] && return 0 + sleep 0.1 + done + fail "josh-proxy did not start" +} + +stop_proxy() { + kill "$JOSH_PID" 2>/dev/null || true + for _ in $(seq 1 20); do kill -0 "$JOSH_PID" 2>/dev/null || break; sleep 0.1; done + kill -0 "$JOSH_PID" 2>/dev/null && fail "josh-proxy did not exit when terminated" + JOSH_PID="" +} + +echo "== setup: upstream repository" +rm -rf "$UPSTREAM_ROOT/upstream.git" +git init -q --bare -b "$BRANCH" "$UPSTREAM_ROOT/upstream.git" +git -C "$UPSTREAM_ROOT/upstream.git" config http.receivepack true +git init -q -b "$BRANCH" "$WORK/seed" +git -C "$WORK/seed" config user.email t@t +git -C "$WORK/seed" config user.name t +echo hello > "$WORK/seed/README.md" +git -C "$WORK/seed" add . && git -C "$WORK/seed" commit -qm "c1: readme" +C1="$(git -C "$WORK/seed" rev-parse HEAD)" +mkdir -p "$WORK/seed/src" && echo lib > "$WORK/seed/src/lib.txt" +git -C "$WORK/seed" add . && git -C "$WORK/seed" commit -qm "c2: lib" +C2="$(git -C "$WORK/seed" rev-parse HEAD)" +git -C "$WORK/seed" push -q "$UPSTREAM_ROOT/upstream.git" "$BRANCH" + +echo "== boot" +start_proxy +FILTERED="http://127.0.0.1:$PORT/upstream.git:prefix=lib.git" + +echo "== filtered clone" +git clone -q "$FILTERED" "$WORK/clone" || fail "filtered clone" +[ -f "$WORK/clone/lib/README.md" ] || fail "prefix missing from the clone" +[ "$(git -C "$WORK/clone" rev-list --count HEAD)" = 2 ] || fail "expected 2 commits" + +echo "== pinned-SHA fetch" +# The filter separator is also exercised percent-encoded, as clients send it. +git -C "$WORK/clone" fetch -q "http://127.0.0.1:$PORT/upstream.git@$C1%3Aprefix=lib.git" HEAD \ + || fail "pinned fetch" +git -C "$WORK/clone" ls-tree --name-only -r FETCH_HEAD | grep -qx "lib/README.md" \ + || fail "pinned fetch: README missing" +git -C "$WORK/clone" ls-tree --name-only -r FETCH_HEAD | grep -q "lib/src" \ + && fail "pinned fetch resolved past the pinned commit" + +echo "== reverse push" +git -C "$WORK/clone" config user.email t@t +git -C "$WORK/clone" config user.name t +echo change >> "$WORK/clone/lib/src/lib.txt" +git -C "$WORK/clone" commit -qam "c3: change through the filter" +git -C "$WORK/clone" push -q -o "base=refs/heads/$BRANCH" origin HEAD:refs/heads/roundtrip \ + || fail "reverse push" +RT="$(git -C "$UPSTREAM_ROOT/upstream.git" rev-parse refs/heads/roundtrip)" \ + || fail "reverse push: branch missing upstream" +git -C "$UPSTREAM_ROOT/upstream.git" show "$RT:src/lib.txt" | grep -q change \ + || fail "reverse push: change not reverse-filtered to src/lib.txt" +[ "$(git -C "$UPSTREAM_ROOT/upstream.git" rev-parse "$RT^")" = "$C2" ] \ + || fail "reverse push: pushed commit is not rooted on the upstream tip" + +echo "== cache reuse across a restart" +# Consumers run one proxy per operation rather than a daemon, so the --local +# cache has to survive a clean stop and serve the next instance. +stop_proxy +start_proxy +git -C "$WORK/clone" fetch -q origin || fail "fetch against the reused cache" + +stop_proxy +echo "PASS" diff --git a/tests/windows/run.ps1 b/tests/windows/run.ps1 new file mode 100644 index 000000000..f34920031 --- /dev/null +++ b/tests/windows/run.ps1 @@ -0,0 +1,95 @@ +<# +.SYNOPSIS +Run the Windows functional tests against built josh binaries. + +.DESCRIPTION +`josh compose` needs podman and does not run on Windows, so the .t suites are +out of reach there. These drive the built binaries directly: the CLI against a +local repository, and josh-proxy against a git server hosted by serve-git.ps1. + +.PARAMETER BinDir +Directory holding josh-proxy.exe, josh.exe and josh-filter.exe. + +.PARAMETER PathForms +Also run the proxy tests with relative, space-laden and junction cache +directories. + +.EXAMPLE +tests\windows\run.ps1 target\release +#> +param( + [Parameter(Mandatory = $true)][string]$BinDir, + [switch]$PathForms +) + +$ErrorActionPreference = 'Stop' + +$bash = @( + "$env:ProgramFiles\Git\bin\bash.exe", + "${env:ProgramFiles(x86)}\Git\bin\bash.exe", + "$env:LOCALAPPDATA\Programs\Git\bin\bash.exe" +) | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $bash) { throw "Git Bash not found; install Git for Windows" } + +$BinDir = (Resolve-Path $BinDir).Path +$here = $PSScriptRoot +$port = 8177 + +# Git Bash passes these to native tools, which want /c/... spellings. +function To-BashPath([string]$p) { + $p = $p -replace '\\', '/' + if ($p -match '^([A-Za-z]):(.*)$') { return "/$($Matches[1].ToLower())$($Matches[2])" } + return $p +} + +$results = [ordered]@{} +function Run-Test([string]$name, [string[]]$bashArgs, [hashtable]$extraEnv = @{}) { + Write-Host "`n=== $name" -ForegroundColor Cyan + foreach ($k in $extraEnv.Keys) { Set-Item "env:$k" $extraEnv[$k] } + & $bash @bashArgs + $results[$name] = ($LASTEXITCODE -eq 0) +} + +Run-Test 'cli' @((To-BashPath "$here\cli.sh"), (To-BashPath $BinDir)) + +$served = Join-Path ([System.IO.Path]::GetTempPath()) "josh-served-$PID" +New-Item -ItemType Directory -Force -Path $served | Out-Null +$server = Start-Process pwsh -PassThru -WindowStyle Hidden -ArgumentList @( + '-NoProfile', '-File', "$here\serve-git.ps1", '-Root', $served, '-Port', $port) + +try { + $ready = $false + foreach ($i in 1..100) { + try { (New-Object Net.Sockets.TcpClient('127.0.0.1', $port)).Close(); $ready = $true; break } + catch { Start-Sleep -Milliseconds 100 } + } + if (-not $ready) { throw "git server did not start on port $port" } + + $proxy = Join-Path $BinDir 'josh-proxy.exe' + $env:UPSTREAM_URL = "http://127.0.0.1:$port" + + Run-Test 'proxy' @((To-BashPath "$here\proxy.sh"), (To-BashPath $proxy)) ` + @{ UPSTREAM_ROOT = (To-BashPath $served) } + + if ($PathForms) { + $tmp = $env:TEMP + foreach ($case in @( + @{ name = 'proxy: relative cache path'; dir = './josh-rel' }, + @{ name = 'proxy: cache path with spaces'; dir = (To-BashPath (Join-Path $tmp 'josh cache spaces')) } + )) { + Run-Test $case.name @((To-BashPath "$here\proxy.sh"), (To-BashPath $proxy), $case.dir) ` + @{ UPSTREAM_ROOT = (To-BashPath $served) } + } + } +} finally { + if ($server -and -not $server.HasExited) { Stop-Process -Id $server.Id -Force } + Remove-Item -Recurse -Force $served -ErrorAction SilentlyContinue +} + +Write-Host "`n=== verdict" -ForegroundColor Cyan +foreach ($k in $results.Keys) { + if ($results[$k]) { Write-Host " PASS $k" -ForegroundColor Green } + else { Write-Host " FAIL $k" -ForegroundColor Red } +} +if ($results.Values -contains $false) { exit 1 } +Write-Host "`nAll Windows functional tests passed." -ForegroundColor Green diff --git a/tests/windows/serve-git.ps1 b/tests/windows/serve-git.ps1 new file mode 100644 index 000000000..5ec350049 --- /dev/null +++ b/tests/windows/serve-git.ps1 @@ -0,0 +1,120 @@ +<# +.SYNOPSIS +Serve bare git repositories over smart HTTP, for tests. + +.DESCRIPTION +josh-proxy only accepts an http(s) or ssh upstream, so testing it needs a git +server. Rather than add a dependency, this hosts git's own http-backend as CGI +behind System.Net.HttpListener, which ships with Windows. + +Prints the URL it is serving on, then runs until stopped. + +.PARAMETER Root +Directory holding bare repositories (GIT_PROJECT_ROOT). + +.PARAMETER Port +Port to listen on. 127.0.0.1 only. +#> +param( + [Parameter(Mandatory = $true)][string]$Root, + [int]$Port = 8177 +) + +$ErrorActionPreference = 'Stop' + +$git = (Get-Command git).Source +$root = (Resolve-Path $Root).Path + +$listener = [System.Net.HttpListener]::new() +$listener.Prefixes.Add("http://127.0.0.1:$Port/") +$listener.Start() +Write-Host "serving $root on http://127.0.0.1:$Port/" + +try { + while ($listener.IsListening) { + $context = $listener.GetContext() + $request = $context.Request + $response = $context.Response + + $psi = [System.Diagnostics.ProcessStartInfo]::new($git, 'http-backend') + $psi.UseShellExecute = $false + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + + $psi.Environment['GIT_PROJECT_ROOT'] = $root + $psi.Environment['GIT_HTTP_EXPORT_ALL'] = '1' + $psi.Environment['REQUEST_METHOD'] = $request.HttpMethod + $psi.Environment['PATH_INFO'] = [Uri]::UnescapeDataString($request.Url.AbsolutePath) + $psi.Environment['QUERY_STRING'] = $request.Url.Query.TrimStart('?') + $psi.Environment['REMOTE_ADDR'] = $request.RemoteEndPoint.Address.ToString() + $psi.Environment['REMOTE_USER'] = 'test' + if ($request.ContentType) { $psi.Environment['CONTENT_TYPE'] = $request.ContentType } + if ($request.ContentLength64 -ge 0) { $psi.Environment['CONTENT_LENGTH'] = "$($request.ContentLength64)" } + # http-backend inflates the body itself when told the encoding, and serves + # protocol v2 only when the client's version is passed through. + if ($request.Headers['Content-Encoding']) { + $psi.Environment['HTTP_CONTENT_ENCODING'] = $request.Headers['Content-Encoding'] + } + if ($request.Headers['Git-Protocol']) { + $psi.Environment['GIT_PROTOCOL'] = $request.Headers['Git-Protocol'] + } + + $process = [System.Diagnostics.Process]::Start($psi) + + # git speaks binary over HTTP: every copy is bytes, and stderr is drained on + # its own so a chatty backend cannot fill the pipe and block. + $stderrTask = $process.StandardError.ReadToEndAsync() + if ($request.HasEntityBody) { + $request.InputStream.CopyTo($process.StandardInput.BaseStream) + } + $process.StandardInput.Close() + + $captured = New-Object System.IO.MemoryStream + $process.StandardOutput.BaseStream.CopyTo($captured) + $process.WaitForExit() + $bytes = $captured.ToArray() + + # CGI replies with headers, a blank line, then the body. Buffering the whole + # reply keeps the body's bytes intact and lets the response carry a real + # Content-Length, which the git client needs to know where it ends. + $split = -1 + for ($i = 0; $i -lt $bytes.Length - 1; $i++) { + if ($bytes[$i] -eq 10 -and $bytes[$i + 1] -eq 10) { $split = $i + 2; break } + if ($i -lt $bytes.Length - 3 -and $bytes[$i] -eq 13 -and $bytes[$i + 1] -eq 10 ` + -and $bytes[$i + 2] -eq 13 -and $bytes[$i + 3] -eq 10) { $split = $i + 4; break } + } + if ($split -lt 0) { + $response.StatusCode = 500 + $message = [System.Text.Encoding]::UTF8.GetBytes("no CGI reply from git http-backend`n$($stderrTask.Result)") + $response.ContentLength64 = $message.Length + $response.OutputStream.Write($message, 0, $message.Length) + $response.OutputStream.Close() + continue + } + + $headerText = [System.Text.Encoding]::ASCII.GetString($bytes, 0, $split) + $body = New-Object byte[] ($bytes.Length - $split) + [Array]::Copy($bytes, $split, $body, 0, $body.Length) + + $response.StatusCode = 200 + foreach ($line in ($headerText -split "`r?`n")) { + if (-not $line) { continue } + $name, $value = $line -split ':\s*', 2 + switch ($name) { + 'Status' { $response.StatusCode = [int]($value -split ' ')[0] } + 'Content-Type' { $response.ContentType = $value } + 'Content-Length' { } # taken from the body below + default { try { $response.Headers[$name] = $value } catch { } } + } + } + + $response.SendChunked = $false + $response.KeepAlive = $false + $response.ContentLength64 = $body.Length + if ($body.Length -gt 0) { $response.OutputStream.Write($body, 0, $body.Length) } + $response.OutputStream.Close() + } +} finally { + $listener.Stop() +}