userland/git: git-compatible CLI on v2's gix code (T10)

Local workflow, smart-HTTP clone/fetch/push and public HTTPS clone/fetch match
native git in stdout and exit codes. Only WASI imports (wasi:http, exit-with-code).
This commit is contained in:
2026-11-06 16:07:57 +00:00
parent 0fae0f9b10
commit 4b27e9afd5
204 changed files with 37736 additions and 0 deletions
+3
View File
@@ -6,3 +6,6 @@ target/
# Lock files of test and example crates: they resolve against the workspace lock.
**/tests/**/Cargo.lock
/examples/*/Cargo.lock
# Cargo homes that the devshell creates where it is entered.
.cargo-home/
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "git"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4", features = ["derive"] }
gix = { version = "0.90", default-features = false, features = ["sha1", "blob-diff", "revision", "index", "status", "merge", "blocking-network-client"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
gix-transport = { version = "0.62", features = ["http-client"] }
gix-error = "0.5"
gix-pack = { version = "0.77", default-features = false, features = ["sha1", "generate"] }
gix-zlib = "0.4"
url = "2"
wasi = "0.14"
+76
View File
@@ -0,0 +1,76 @@
# Git for WASI 0.2
This crate adapts the v2 Git plugin's local operations and HTTP transport.
It uses gix 0.90 and the patches in [`vendor/PATCHES.md`](vendor/PATCHES.md).
It does not use the plugin SDK, RPC dispatch, the vault, or SSH.
The host supplies credentials through standard `wasi:http`.
## Build
Run the build from `userland/` to include the workspace's cwd constructor:
```sh
CARGO_TARGET_DIR=/tmp/p1-git/target nix develop .. -c cargo build -p git --release
```
The following commands run from the repository root.
## Native/WASI comparison and import check
```sh
nix develop -c nu userland/git/test.nu /tmp/p1-git/target/wasm32-wasip2/release/git.wasm
```
The script checks that the component imports only WASI interfaces.
It creates separate native and WASI repositories under `/tmp/p1-git/`.
It fixes both identities and timestamps.
It compares stdout and exit codes without filtering command output.
Initialization and worktree listings compare exit codes only because their paths differ.
The script retains its fixtures for inspection.
`wasmtime run` needs `-S http`, including for local operations.
It also needs `-S cli-exit-with-code=y` to preserve Git's numeric exit codes.
The component uses WASI's `exit-with-code`, not a custom host import.
The world host must enable that optional WASI function.
The tests pass explicit `-C` paths.
## Local smart-HTTP test
Build the native fixture server:
```sh
CARGO_TARGET_DIR=/tmp/p1-git/server-target nix develop -c cargo build --manifest-path userland/git/tests/http-server/Cargo.toml
```
The server wraps native `git http-backend` without TLS.
It accepts a repository directory and a readiness file.
The readiness file receives the bound localhost port.
Start the server in a separate terminal:
```sh
mkdir -p /tmp/p1-git/http-repos
/tmp/p1-git/server-target/debug/git-test-http-server /tmp/p1-git/http-repos /tmp/p1-git/http-port
```
Use that port in the integration test:
```sh
nix develop -c nu userland/git/http-test.nu /tmp/p1-git/target/wasm32-wasip2/release/git.wasm /tmp/p1-git/http-repos http://127.0.0.1:PORT/repo.git
```
Use a fresh repository name for each test run.
The script checks anonymous clone, fetch, and push through WASI.
It checks the resulting object IDs with native Git.
## Anonymous HTTPS test
The host handles TLS, not the guest.
Test HTTPS separately against the small public Hello-World repository:
```sh
nix develop -c nu userland/git/public-https-test.nu /tmp/p1-git/target/wasm32-wasip2/release/git.wasm
```
This test clones the repository and fetches it again.
It does not need credentials.
HTTPS push requires the host's later credential injection.
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env nu
# The HTTP server serves server-root through native git http-backend.
use std/assert
def main [component: path, server_root: path, url: string] {
let component = ($component | path expand)
let server_root = ($server_root | path expand)
let fixture = (mktemp -d -p /tmp/p1-git https.XXXXXX)
let seed = ($fixture | path join seed)
mkdir $seed
let envs = {HOME: '/tmp/p1-git/empty-home', GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'Agent', GIT_AUTHOR_EMAIL: 'agent@example.test', GIT_COMMITTER_NAME: 'Agent', GIT_COMMITTER_EMAIL: 'agent@example.test', GIT_AUTHOR_DATE: '1700000000 +0000', GIT_COMMITTER_DATE: '1700000000 +0000'}
with-env $envs {
run-native [init -b main $seed]
run-native [-C $seed config user.name Agent]
run-native [-C $seed config user.email agent@example.test]
'base' | save ($seed | path join file)
run-native [-C $seed add .]
run-native [-C $seed commit -m base]
let remote = ($server_root | path join ($url | url parse | get path | path basename))
run-native [clone --bare $seed $remote]
run-native [-C $remote config http.receivepack 'true']
wasi $component $fixture [clone $url /clone]
wasi $component $fixture [-C /clone config user.name Agent]
wasi $component $fixture [-C /clone config user.email agent@example.test]
let clone = ($fixture | path join clone)
assert ((open --raw ($clone | path join file)) == 'base')
'native' | save --force ($seed | path join file)
run-native [-C $seed commit -am native]
run-native [-C $seed push $remote main]
wasi $component $fixture [-C /clone fetch]
let expected = (^git -C $remote rev-parse main | str trim)
let actual = (^git -C $clone rev-parse origin/main | str trim)
assert ($expected == $actual)
wasi $component $fixture [-C /clone merge origin/main]
'guest' | save --force ($clone | path join guest)
wasi $component $fixture [-C /clone add .]
wasi $component $fixture [-C /clone commit -m guest]
wasi $component $fixture [-C /clone push origin main]
let expected = (^git -C $clone rev-parse HEAD | str trim)
let actual = (^git -C $remote rev-parse main | str trim)
assert ($expected == $actual)
print 'Local smart-HTTP clone, fetch, and push: PASS'
print $'Fixture: ($fixture)'
}
}
def run-native [args: list<string>] {
let result = (^git ...$args | complete)
if $result.exit_code != 0 {error make {msg: ($result | to json)}}
}
def wasi [component: path, fixture: path, args: list<string>] {
let result = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($fixture)::/' --env GIT_AUTHOR_NAME --env GIT_AUTHOR_EMAIL --env GIT_COMMITTER_NAME --env GIT_COMMITTER_EMAIL --env GIT_AUTHOR_DATE --env GIT_COMMITTER_DATE $component ...$args | complete)
if $result.exit_code != 0 {error make {msg: ($result | to json)}}
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env nu
# The host handles TLS. Test anonymous HTTPS against a small public repository.
use std/assert
def main [component: path] {
let component = ($component | path expand)
let fixture = (mktemp -d -p /tmp/p1-git public-https.XXXXXX)
with-env {GIT_AUTHOR_NAME: 'Agent', GIT_AUTHOR_EMAIL: 'agent@example.test', GIT_COMMITTER_NAME: 'Agent', GIT_COMMITTER_EMAIL: 'agent@example.test'} {
for args in [[clone https://github.com/octocat/Hello-World.git /hello] [-C /hello config user.name Agent] [-C /hello config user.email agent@example.test] [-C /hello fetch]] {
let result = (^wasmtime run -S http -S cli-exit-with-code=y --dir $'($fixture)::/' --env GIT_AUTHOR_NAME --env GIT_AUTHOR_EMAIL --env GIT_COMMITTER_NAME --env GIT_COMMITTER_EMAIL $component ...$args | complete)
if $result.exit_code != 0 {error make {msg: ($result | to json)}}
print $'HTTPS ($args | str join " "): PASS'
}
assert (($fixture | path join hello README) | path exists)
print $'Fixture: ($fixture)'
}
}
+322
View File
@@ -0,0 +1,322 @@
//! Branch/HEAD plumbing. All mutators are called under the common .git lock.
use crate::Result;
use crate::{HeadInfo, checkout::write_tree_to_worktree, head_info, repo::error};
/// Switch a branch, tag, or commit; optionally create a branch at HEAD.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct CheckoutParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Branch, tag or commit revision; with create=true, the new branch name.
pub target: String,
/// Create a new branch at HEAD and switch to it (default false).
pub create: Option<bool>,
/// Discard overwritten tracked changes, never untracked files (default false).
pub force: Option<bool>,
}
/// Create a local branch, optionally switching the worktree to it.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct BranchCreateParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Short new branch name.
pub name: String,
/// Starting commit revision, default HEAD.
pub from: Option<String>,
/// Switch to the new branch, refusing overwritten dirty changes (default false).
pub checkout: Option<bool>,
}
/// Delete a local branch.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct BranchDeleteParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Short existing branch name.
pub name: String,
/// Allow deletion when not merged into HEAD (default false). Current branch is always protected.
pub force: Option<bool>,
}
/// Result of a checkout or branch creation.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct CheckoutResult {
/// HEAD after the operation.
pub head: HeadInfo,
/// Full branch reference created, if any.
pub created: Option<String>,
/// Unsupported worktree features (symlinks/submodules/chmod); raw content has no filters or eol conversion.
pub warnings: Vec<String>,
}
/// Deleted branch identity.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct BranchDeleted {
/// Full reference deleted.
pub reference: String,
}
/// Reflogs require an identity, but switching branches must not require configuring one.
/// With no repository identity, leave reflogs untouched and report that explicitly; never invent an author.
pub(crate) fn edit_refs(
repo: &gix::Repository,
edits: Vec<gix::refs::transaction::RefEdit>,
) -> Result<Vec<String>> {
let committer = repo
.committer()
.transpose()
.map_err(|e| error("reflog identity", e))?;
if let Some(committer) = committer {
repo.edit_references_as(edits, Some(committer))
.map_err(|e| error("checkout refs", e))?;
Ok(Vec::new())
} else {
let mut local = repo.clone();
local.refs.write_reflog = gix::refs::store::WriteReflog::Disable;
local
.edit_references_as(edits, None)
.map_err(|e| error("checkout refs", e))?;
Ok(vec![
"reflog not written: no repository committer identity configured".into(),
])
}
}
pub(crate) fn branch_name(name: &str) -> Result<String> {
if name.is_empty() || name.starts_with('-') || name.starts_with("refs/") || name == "HEAD" {
return Err(error("branch name", "expected short local branch name"));
}
let full = format!("refs/heads/{name}");
let _: gix::refs::FullName = full
.as_str()
.try_into()
.map_err(|e| error("branch name", e))?;
Ok(full)
}
fn unused(repo: &gix::Repository, name: &str) -> Result<()> {
if repo
.try_find_reference(name)
.map_err(|e| error("branch lookup", e))?
.is_some()
{
return Err(error("branch create", "branch already exists"));
}
Ok(())
}
pub(crate) fn resolve(repo: &gix::Repository, rev: &str) -> Result<gix::ObjectId> {
Ok(repo
.rev_parse_single(rev)
.map_err(|e| error("checkout revision", e))?
.object()
.map_err(|e| error("checkout object", e))?
.peel_to_kind(gix::objs::Kind::Commit)
.map_err(|e| error("checkout commit", e))?
.id)
}
fn old_tree(repo: &gix::Repository) -> Result<Option<gix::ObjectId>> {
if repo
.head()
.map_err(|e| error("checkout HEAD", e))?
.is_unborn()
{
return Ok(None);
}
let id = resolve(repo, "HEAD")?;
Ok(Some(
repo.find_commit(id)
.map_err(|e| error("checkout HEAD", e))?
.tree_id()
.map_err(|e| error("checkout HEAD tree", e))?
.detach(),
))
}
fn switch(
repo: &gix::Repository,
id: gix::ObjectId,
branch: Option<&str>,
create: bool,
force: bool,
) -> Result<Vec<String>> {
crate::merge::ensure_idle(repo)?;
if let Some(branch) = branch {
crate::worktrees::ensure_available(repo, branch)?;
}
let tree = repo
.find_commit(id)
.map_err(|e| error("checkout commit", e))?
.tree_id()
.map_err(|e| error("checkout tree", e))?
.detach();
// Validate identity before publishing worktree/index. edit_refs resolves the same
// immutable repository config again when publishing refs, but must not discover a
// malformed identity only after files have changed.
repo.committer()
.transpose()
.map_err(|e| error("reflog identity", e))?;
let mut warnings = write_tree_to_worktree(repo, old_tree(repo)?, tree, force)?;
// Index already published. Refs last, using gix transactions (including packed refs and reflogs).
use gix::refs::{
Target,
transaction::{Change, PreviousValue, RefEdit},
};
let mut edits = Vec::new();
if create {
let name = branch.ok_or_else(|| error("checkout", "missing new branch"))?;
edits.push(RefEdit {
name: name.try_into().map_err(|e| error("branch name", e))?,
deref: false,
change: Change::Update {
log: Default::default(),
expected: PreviousValue::MustNotExist,
new: Target::Object(id),
},
});
}
let target = match branch {
Some(name) => Target::Symbolic(name.try_into().map_err(|e| error("branch name", e))?),
None => Target::Object(id),
};
edits.push(RefEdit {
name: "HEAD".try_into().map_err(|e| error("HEAD", e))?,
deref: false,
change: Change::Update {
log: Default::default(),
expected: PreviousValue::Any,
new: target,
},
});
warnings.extend(edit_refs(repo, edits)?);
Ok(warnings)
}
pub(crate) fn checkout(repo: &gix::Repository, p: CheckoutParams) -> Result<CheckoutResult> {
crate::repo::worktree(repo)?;
let create = p.create.unwrap_or(false);
let (id, branch) = if create {
let name = branch_name(&p.target)?;
crate::worktrees::ensure_available(repo, &name)?;
unused(repo, &name)?;
(resolve(repo, "HEAD")?, Some(name))
} else {
// Local branches take precedence over tags; tags and all other revisions detach HEAD.
let name = if p.target.starts_with("refs/heads/") {
p.target.clone()
} else {
format!("refs/heads/{}", p.target)
};
// Revision operators are not valid reference names. Only probe a local
// branch when its full name validates; otherwise let rev_parse interpret it.
let reference = if <gix::refs::FullName as TryFrom<&str>>::try_from(name.as_str()).is_ok() {
crate::worktrees::ensure_available(repo, &name)?;
repo.try_find_reference(name.as_str())
.map_err(|e| error("checkout branch", e))?
} else {
None
};
if let Some(mut reference) = reference {
let id = reference
.peel_to_id()
.map_err(|e| error("checkout branch", e))?
.detach();
(id, Some(name))
} else {
(resolve(repo, &p.target)?, None)
}
};
let warnings = switch(
repo,
id,
branch.as_deref(),
create,
p.force.unwrap_or(false),
)?;
Ok(CheckoutResult {
head: head_info(repo)?,
created: if create { branch } else { None },
warnings,
})
}
pub(crate) fn create(repo: &gix::Repository, p: BranchCreateParams) -> Result<CheckoutResult> {
let name = branch_name(&p.name)?;
if p.checkout.unwrap_or(false) {
crate::worktrees::ensure_available(repo, &name)?;
}
unused(repo, &name)?;
let id = resolve(repo, p.from.as_deref().unwrap_or("HEAD"))?;
let warnings = if p.checkout.unwrap_or(false) {
switch(repo, id, Some(&name), true, false)?
} else {
use gix::refs::{
Target,
transaction::{Change, PreviousValue, RefEdit},
};
edit_refs(
repo,
vec![RefEdit {
name: name
.as_str()
.try_into()
.map_err(|e| error("branch name", e))?,
deref: false,
change: Change::Update {
log: Default::default(),
expected: PreviousValue::MustNotExist,
new: Target::Object(id),
},
}],
)?
};
Ok(CheckoutResult {
head: head_info(repo)?,
created: Some(name),
warnings,
})
}
pub(crate) fn delete(repo: &gix::Repository, p: BranchDeleteParams) -> Result<BranchDeleted> {
crate::merge::ensure_idle(repo)?;
let name = branch_name(&p.name)?;
crate::worktrees::ensure_available(repo, &name)?;
if head_info(repo)?.branch.as_deref() == Some(&name) {
return Err(error("branch delete", "cannot delete current branch"));
}
let mut reference = repo
.find_reference(name.as_str())
.map_err(|e| error("branch delete", e))?;
let tip = reference
.peel_to_id()
.map_err(|e| error("branch delete", e))?
.detach();
if !p.force.unwrap_or(false) {
let head = resolve(repo, "HEAD")?;
// Explicit traversal avoids commit-graph mmap on WASI; includes every parent.
let walk = repo
.find_commit(head)
.map_err(|e| error("branch merged", e))?
.ancestors()
.use_commit_graph(false)
.all()
.map_err(|e| error("branch merged", e))?;
let mut merged = false;
for entry in walk {
if entry.map_err(|e| error("branch merged", e))?.id == tip {
merged = true;
break;
}
}
if !merged {
return Err(error(
"branch delete",
"branch is not merged into HEAD; use force",
));
}
}
reference.delete().map_err(|e| error("branch delete", e))?;
Ok(BranchDeleted { reference: name })
}
+600
View File
@@ -0,0 +1,600 @@
//! Read-only snapshots and patches. Ordinary Git state only; never refresh/write the index.
use crate::Result;
use crate::{
HeadInfo, head_info,
repo::{self, error},
};
use gix::bstr::ByteSlice;
use std::collections::{BTreeMap, BTreeSet};
/// Change classification. Renames are exact-content matches, not similarity guesses.
#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq, Debug)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
Added,
Modified,
Deleted,
Renamed,
}
/// One changed repository-relative path.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Change {
/// Destination path (old path for a deletion), invalid UTF-8 replaced.
pub path: String,
/// Classification; mode/type changes are modified.
pub status: Kind,
/// Source path for exact-content renames.
pub old_path: Option<String>,
}
/// Repository status without index refresh or other writes.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Status {
/// Symbolic, detached or unborn HEAD.
pub head: HeadInfo,
/// HEAD tree to stage-zero index, excluding conflicts.
pub staged: Vec<Change>,
/// Index to worktree; conflicts listed separately.
pub unstaged: Vec<Change>,
/// Individual untracked files, excluding ignored paths.
pub untracked: Vec<String>,
/// Paths with nonzero index stages.
pub conflicted: Vec<String>,
}
/// Diff query with mutually exclusive modes.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct DiffParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Old revision; must be supplied together with to, without staged=true.
pub from: Option<String>,
/// New revision (commit, tag or tree).
pub to: Option<String>,
/// Compare HEAD to index instead of index to worktree; default false.
pub staged: Option<bool>,
/// Literal relative file/directory filter, matches either rename endpoint.
pub path: Option<String>,
/// Unchanged lines around each hunk; default 3.
pub context: Option<u32>,
}
/// One changed file and its patch.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct FileDiff {
/// Destination path, or old path for deletion.
pub path: String,
/// Added, modified, deleted or exact-content renamed.
pub status: Kind,
/// Rename source, otherwise null.
pub old_path: Option<String>,
/// True for NUL-containing or non-UTF-8 content; patch is empty.
pub binary: bool,
/// Unified patch with file headers and mode/rename metadata. Empty for binary files.
pub patch: String,
}
#[derive(Clone, PartialEq, Eq)]
struct Entry {
id: gix::ObjectId,
mode: u32,
}
type Snapshot = BTreeMap<Vec<u8>, Entry>;
fn index(repo: &gix::Repository) -> Result<(Snapshot, BTreeSet<Vec<u8>>)> {
let index = repo.index_or_empty().map_err(|e| error("index", e))?;
let mut entries = Snapshot::new();
let mut conflicts = BTreeSet::new();
for entry in index.entries() {
let path = entry.path(&index).to_vec();
if entry.mode == gix::index::entry::Mode::DIR
|| entry
.flags
.contains(gix::index::entry::Flags::INTENT_TO_ADD)
{
return Err(error(
"index",
"sparse-directory and intent-to-add entries are not supported by status/diff",
));
}
if entry.stage() != gix::index::entry::Stage::Unconflicted {
conflicts.insert(path);
} else {
entries.insert(
path,
Entry {
id: entry.id,
mode: entry.mode.bits(),
},
);
}
}
Ok((entries, conflicts))
}
fn tree(repo: &gix::Repository, rev: &str) -> Result<Snapshot> {
if rev == "HEAD" && repo.head().map_err(|e| error("diff HEAD", e))?.is_unborn() {
return Ok(Snapshot::new());
}
let obj = repo
.rev_parse_single(rev)
.map_err(|e| error("diff revision", e))?
.object()
.map_err(|e| error("diff object", e))?
.peel_to_kind(gix::objs::Kind::Tree)
.map_err(|e| error("diff tree", e))?;
let mut result = Snapshot::new();
let mut pending = vec![(Vec::new(), obj.id)];
while let Some((prefix, id)) = pending.pop() {
let tree = repo.find_tree(id).map_err(|e| error("diff tree", e))?;
for entry in tree.iter() {
let entry = entry.map_err(|e| error("diff entry", e))?;
let mut path = prefix.clone();
path.extend_from_slice(entry.filename());
if entry.mode().is_tree() {
path.push(b'/');
pending.push((path, entry.oid().to_owned()));
} else {
result.insert(
path,
Entry {
id: entry.oid().to_owned(),
mode: entry.mode().kind() as u32,
},
);
}
}
}
Ok(result)
}
fn display(path: &[u8]) -> String {
String::from_utf8_lossy(path).into_owned()
}
/// Git C-style byte quoting, so patch paths remain exact even when RPC display paths are lossy.
fn quote(prefix: &str, path: &[u8]) -> String {
if prefix
.as_bytes()
.iter()
.chain(path)
.all(|b| matches!(b, 32..=126) && !matches!(b, b'"' | b'\\'))
{
return format!("{prefix}{}", display(path));
}
let mut out = String::from("\"");
for &byte in prefix.as_bytes().iter().chain(path) {
match byte {
b'"' => out.push_str("\\\""),
b'\\' => out.push_str("\\\\"),
b'\n' => out.push_str("\\n"),
b'\r' => out.push_str("\\r"),
b'\t' => out.push_str("\\t"),
32..=126 => out.push(byte as char),
_ => out.push_str(&format!("\\{byte:03o}")),
}
}
out.push('"');
out
}
// Internal byte paths preserve identity even when the RPC display is lossy.
fn compare(old: &Snapshot, new: &Snapshot) -> Vec<(Vec<u8>, Option<Vec<u8>>, Kind)> {
let mut deleted: BTreeSet<_> = old
.keys()
.filter(|p| !new.contains_key(*p))
.cloned()
.collect();
let mut result = Vec::new();
for (path, entry) in new {
match old.get(path) {
Some(before) if before == entry => (),
Some(_) => result.push((path.clone(), None, Kind::Modified)),
None => {
// Git does not pair empty blobs as renames by default.
let empty =
gix::objs::compute_hash(entry.id.kind(), gix::objs::Kind::Blob, b"").ok();
let source = if Some(entry.id) == empty {
None
} else {
deleted.iter().find(|p| old.get(*p) == Some(entry)).cloned()
};
if let Some(source) = source {
deleted.remove(&source);
result.push((path.clone(), Some(source), Kind::Renamed));
} else {
result.push((path.clone(), None, Kind::Added));
}
}
}
}
result.extend(deleted.into_iter().map(|p| (p, None, Kind::Deleted)));
result.sort_by(|a, b| a.0.cmp(&b.0));
result
}
fn changes(old: &Snapshot, new: &Snapshot) -> Vec<Change> {
compare(old, new)
.into_iter()
.map(|(path, source, status)| Change {
path: display(&path),
old_path: source.map(|p| display(&p)),
status,
})
.collect()
}
pub fn status(repo: &gix::Repository) -> Result<Status> {
repo::worktree(repo)?;
let (index, conflicts) = index(repo)?;
let mut head = tree(repo, "HEAD")?;
for path in &conflicts {
head.remove(path);
}
let mut result = Status {
head: head_info(repo)?,
staged: changes(&head, &index),
unstaged: vec![],
untracked: vec![],
conflicted: conflicts.iter().map(|p| display(p)).collect(),
};
use gix::status::index_worktree::{Item, iter::Summary};
let iter = repo
.status(gix::progress::Discard)
.map_err(|e| error("status", e))?
.untracked_files(gix::status::UntrackedFiles::Files)
.into_index_worktree_iter(Vec::<gix::bstr::BString>::new())
.map_err(|e| error("status", e))?;
for item in iter {
let item = item.map_err(|e| error("status", e))?;
let path = item.rela_path().to_str_lossy().into_owned();
let Some(summary) = item.summary() else {
continue;
};
if conflicts.contains(item.rela_path().as_bytes()) {
continue;
}
if matches!(item, Item::DirectoryContents { .. }) {
result.untracked.push(path);
continue;
}
if summary == Summary::Conflict {
continue;
}
result.unstaged.push(Change {
path,
old_path: None,
status: if summary == Summary::Removed {
Kind::Deleted
} else {
Kind::Modified
},
});
}
result.unstaged.sort_by(|a, b| a.path.cmp(&b.path));
result.untracked.sort();
Ok(result)
}
fn blob(repo: &gix::Repository, entry: Option<&Entry>) -> Result<Vec<u8>> {
match entry {
None => Ok(vec![]),
Some(e) if e.mode == 0o160000 => Ok(format!("Subproject commit {}\n", e.id).into_bytes()),
Some(e) => Ok(repo
.find_blob(e.id)
.map_err(|e| error("diff blob", e))?
.data
.clone()),
}
}
fn matches_path(path: &[u8], filter: Option<&str>) -> bool {
filter.is_none_or(|filter| {
path == filter.as_bytes()
|| path
.strip_prefix(filter.as_bytes())
.is_some_and(|rest| rest.starts_with(b"/"))
})
}
// Read at most one worktree file at a time. No repository-wide blob-byte cache.
fn worktree_file(
repo: &gix::Repository,
path: &[u8],
entry: &Entry,
) -> Result<Option<(Entry, Vec<u8>)>> {
let root = repo.workdir().ok_or_else(|| error("diff", "no worktree"))?;
let caps = repo
.filesystem_options()
.map_err(|e| error("diff filesystem", e))?;
if entry.mode == 0o160000 {
return Err(error(
"diff",
"worktree submodule patches are not supported",
));
}
let file = root.join(gix::path::from_bstr(path.as_bstr()).map_err(|e| error("diff path", e))?);
let meta = match std::fs::symlink_metadata(&file) {
Ok(m) => m,
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
return Ok(None);
}
Err(e) => return Err(error("diff stat", e)),
};
if meta.is_dir() {
return Ok(None);
}
let bytes = if meta.file_type().is_symlink() {
gix::path::into_bstr(std::fs::read_link(&file).map_err(|e| error("diff link", e))?)
.map_err(|e| error("diff link path", e))?
.into_owned()
.to_vec()
} else {
std::fs::read(&file).map_err(|e| error("diff read", e))?
};
let mode = if meta.file_type().is_symlink() {
0o120000
} else if entry.mode == 0o120000 && caps.symlink {
0o100644
} else {
entry.mode
};
#[cfg(unix)]
let mode = if mode != 0o120000 && caps.executable_bit {
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o111 != 0 {
0o100755
} else {
0o100644
}
} else {
mode
};
let id = gix::objs::compute_hash(repo.object_hash(), gix::objs::Kind::Blob, &bytes)
.map_err(|e| error("diff hash", e))?;
Ok(Some((Entry { id, mode }, bytes)))
}
fn worktree(repo: &gix::Repository, index: &Snapshot, filter: Option<&str>) -> Result<Snapshot> {
repo::worktree(repo)?;
let mut snapshot = index.clone();
for (path, entry) in index {
// Filter before stat, read, or hashing (including unsupported submodules).
if !matches_path(path, filter) {
continue;
}
match worktree_file(repo, path, entry)? {
Some((entry, _bytes)) => {
snapshot.insert(path.clone(), entry);
}
None => {
snapshot.remove(path);
}
}
}
Ok(snapshot)
}
pub fn diff(p: DiffParams) -> Result<Vec<FileDiff>> {
if let Some(path) = &p.path {
repo::validate_path(path)?;
}
let repo = repo::open(&p.repo)?;
let (old, new, work) = match (&p.from, &p.to, p.staged.unwrap_or(false)) {
(Some(a), Some(b), false) => (tree(&repo, a)?, tree(&repo, b)?, false),
(None, None, staged) => {
repo::worktree(&repo)?;
let (index, conflicts) = index(&repo)?;
if !conflicts.is_empty() {
return Err(error(
"diff",
"unmerged index: resolve conflicts first (see status.conflicted)",
));
}
if staged {
(tree(&repo, "HEAD")?, index, false)
} else {
let new = worktree(&repo, &index, p.path.as_deref())?;
(index, new, true)
}
}
_ => {
return Err(error(
"diff",
"supply both from and to, without staged=true, or neither revision",
));
}
};
let mut result = Vec::new();
for (path, source, status) in compare(&old, &new) {
let before_path = source.as_ref().unwrap_or(&path);
if !matches_path(&path, p.path.as_deref()) && !matches_path(before_path, p.path.as_deref())
{
continue;
}
let before = old.get(before_path);
let after = new.get(&path);
let a = blob(&repo, before)?;
let b = if work {
match after {
Some(expected) => {
let (actual, bytes) = worktree_file(&repo, &path, expected)?
.ok_or_else(|| error("diff", "worktree changed during diff; retry"))?;
if &actual != expected {
return Err(error("diff", "worktree changed during diff; retry"));
}
bytes
}
None => Vec::new(),
}
} else {
blob(&repo, after)?
};
let binary = a.contains(&0)
|| b.contains(&0)
|| std::str::from_utf8(&a).is_err()
|| std::str::from_utf8(&b).is_err();
let mut patch = String::new();
if !binary {
patch.push_str(&format!(
"diff --git {} {}\n",
quote("a/", before_path),
quote("b/", &path)
));
match (before, after) {
(None, Some(e)) => patch.push_str(&format!("new file mode {:06o}\n", e.mode)),
(Some(e), None) => patch.push_str(&format!("deleted file mode {:06o}\n", e.mode)),
(Some(a), Some(b)) if a.mode != b.mode => patch.push_str(&format!(
"old mode {:06o}\nnew mode {:06o}\n",
a.mode, b.mode
)),
_ => (),
}
if status == Kind::Renamed {
patch.push_str(&format!(
"similarity index 100%\nrename from {}\nrename to {}\n",
quote("", before_path),
quote("", &path)
));
}
if a != b {
let old_id = before
.map(|e| e.id.to_string())
.unwrap_or_else(|| "0".repeat(40));
let new_id = after
.map(|e| e.id.to_string())
.unwrap_or_else(|| "0".repeat(40));
patch.push_str(&format!(
"index {}..{}{}\n",
&old_id[..7],
&new_id[..7],
match (before, after) {
(Some(a), Some(b)) if a.mode == b.mode => format!(" {:06o}", a.mode),
_ => String::new(),
}
));
patch.push_str(&format!(
"--- {}\n+++ {}\n",
if before.is_some() {
quote("a/", before_path)
} else {
"/dev/null".into()
},
if after.is_some() {
quote("b/", &path)
} else {
"/dev/null".into()
}
));
patch.push_str(&unified(
std::str::from_utf8(&a).map_err(|e| error("diff text", e))?,
std::str::from_utf8(&b).map_err(|e| error("diff text", e))?,
p.context.unwrap_or(3) as usize,
));
}
}
result.push(FileDiff {
path: display(&path),
old_path: source.map(|p| display(&p)),
status,
binary,
patch,
});
}
Ok(result)
}
/// Format imara's edit ranges, coalescing context-overlapping hunks and preserving final-newline differences.
fn unified(a: &str, b: &str, context: usize) -> String {
use gix::diff::blob::{Algorithm, Diff, InternedInput};
let left: Vec<_> = a.split_inclusive('\n').collect();
let right: Vec<_> = b.split_inclusive('\n').collect();
let mut input = InternedInput::<&str>::default();
input.update_before(left.iter().copied());
input.update_after(right.iter().copied());
let diff = Diff::compute(Algorithm::Myers, &input);
let hunks: Vec<_> = diff.hunks().collect();
let mut out = String::new();
let mut i = 0;
let line = |out: &mut String, prefix: char, s: &str| {
out.push(prefix);
out.push_str(s);
if !s.ends_with('\n') {
out.push_str("\n\\ No newline at end of file\n");
}
};
while i < hunks.len() {
let first = &hunks[i];
let start_a = (first.before.start as usize).saturating_sub(context);
let start_b = (first.after.start as usize).saturating_sub(context);
let mut last = i;
while last + 1 < hunks.len()
&& (hunks[last + 1].before.start as usize)
.saturating_sub(hunks[last].before.end as usize)
<= context.saturating_mul(2)
{
last += 1;
}
let end_a = (hunks[last].before.end as usize)
.saturating_add(context)
.min(left.len());
let end_b = (hunks[last].after.end as usize)
.saturating_add(context)
.min(right.len());
let range = |start: usize, end: usize| {
let count = end - start;
if count == 1 {
format!("{}", start + 1)
} else {
format!("{},{}", if count == 0 { start } else { start + 1 }, count)
}
};
out.push_str(&format!(
"@@ -{} +{} @@\n",
range(start_a, end_a),
range(start_b, end_b)
));
let mut cursor = start_a;
for h in &hunks[i..=last] {
for s in &left[cursor..h.before.start as usize] {
line(&mut out, ' ', s);
}
for s in &left[h.before.start as usize..h.before.end as usize] {
line(&mut out, '-', s);
}
for s in &right[h.after.start as usize..h.after.end as usize] {
line(&mut out, '+', s);
}
cursor = h.before.end as usize;
}
for s in &left[cursor..end_a] {
line(&mut out, ' ', s);
}
i = last + 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unified_context_coalescing_and_final_newline() {
assert_eq!(
unified(
"a\nb\nc\nd\ne\nf\ng\nh\ni\n",
"a\nB\nc\nd\ne\nf\ng\nH\ni\n",
1
),
"@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n@@ -7,3 +7,3 @@\n g\n-h\n+H\n i\n"
);
assert_eq!(
unified("a\nb\nc\nd\ne\n", "a\nB\nc\nD\ne\n", 1),
"@@ -1,5 +1,5 @@\n a\n-b\n+B\n c\n-d\n+D\n e\n"
);
assert_eq!(
unified("a\n", "a", 0),
"@@ -1 +1 @@\n-a\n+a\n\\ No newline at end of file\n"
);
assert_eq!(unified("a\n", "a\nb\n", 0), "@@ -1,0 +2 @@\n+b\n");
assert_eq!(unified("a\nb\n", "a\n", 0), "@@ -2 +1,0 @@\n-b\n");
assert_eq!(unified("", "x\n", usize::MAX), "@@ -0,0 +1 @@\n+x\n");
assert_eq!(quote("a/", b"plain/file"), "a/plain/file");
assert_eq!(quote("", b"with space"), "with space");
assert_eq!(
quote("a/", b"x\t\"\\\n\x01\xff"),
"\"a/x\\t\\\"\\\\\\n\\001\\377\""
);
}
}
+424
View File
@@ -0,0 +1,424 @@
//! Worktree writer shared by checkout and future clone/merge operations.
//! Caller must hold the repository .git lock. No refs are changed here.
use crate::Result;
use crate::repo::error;
use gix::bstr::ByteSlice;
use gix::index::{
State,
entry::{Mode, Stat},
};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::Path,
};
type Files = BTreeMap<String, (gix::ObjectId, Mode)>;
fn tree_state(repo: &gix::Repository, tree: Option<gix::ObjectId>) -> Result<State> {
match tree {
Some(id) => State::from_tree(&id, &repo.objects, Default::default())
.map_err(|e| error("checkout tree", e)),
None => Ok(State::new(repo.object_hash())),
}
}
fn files(state: &State) -> Result<Files> {
state
.entries()
.iter()
.map(|e| {
let path = e
.path(state)
.to_str()
.map_err(|e| error("checkout path UTF-8", e))?
.to_owned();
crate::repo::validate_path(&path)?;
if path
.split('/')
.any(|s| s.eq_ignore_ascii_case(".git") || s.contains('\\'))
{
return Err(error("checkout path", path));
}
Ok((path, (e.id, e.mode)))
})
.collect()
}
fn metadata(path: &Path) -> Result<Option<fs::Metadata>> {
match fs::symlink_metadata(path) {
Ok(m) => Ok(Some(m)),
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
Ok(None)
}
Err(e) => Err(error(
"checkout metadata",
format!("{}: {e}", path.display()),
)),
}
}
fn regular(mode: Mode) -> bool {
mode == Mode::FILE || mode == Mode::FILE_EXECUTABLE
}
fn clean(repo: &gix::Repository, path: &Path, expected: (gix::ObjectId, Mode)) -> Result<bool> {
let Some(meta) = metadata(path)? else {
// These entries are intentionally not materialized by this writer.
return Ok(!regular(expected.1));
};
if expected.1 == Mode::SYMLINK && meta.file_type().is_symlink() {
let target = fs::read_link(path).map_err(|e| error("checkout symlink", e))?;
let target = target
.to_str()
.ok_or_else(|| error("checkout symlink", "non-UTF-8 link target"))?;
return Ok(gix::objs::compute_hash(
repo.object_hash(),
gix::objs::Kind::Blob,
target.as_bytes(),
)
.map_err(|e| error("checkout symlink hash", e))?
== expected.0);
}
if !meta.is_file() || !regular(expected.1) {
return Ok(false);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if (meta.permissions().mode() & 0o111 != 0) != (expected.1 == Mode::FILE_EXECUTABLE) {
return Ok(false);
}
}
let bytes = fs::read(path).map_err(|e| error("checkout read", e))?;
let id = gix::objs::compute_hash(repo.object_hash(), gix::objs::Kind::Blob, &bytes)
.map_err(|e| error("checkout hash", e))?;
Ok(id == expected.0)
}
/// Verify every leaf in a directory is tracked and scheduled for removal. Never remove untracked leaves.
fn removable_dir(root: &Path, dir: &Path, removed: &BTreeSet<String>) -> Result<bool> {
for child in fs::read_dir(dir).map_err(|e| error("checkout directory", e))? {
let child = child.map_err(|e| error("checkout directory", e))?;
let path = child.path();
let meta = fs::symlink_metadata(&path).map_err(|e| error("checkout metadata", e))?;
if meta.is_dir() {
if !removable_dir(root, &path, removed)? {
return Ok(false);
}
} else if !path
.strip_prefix(root)
.ok()
.and_then(Path::to_str)
.is_some_and(|p| removed.contains(p))
{
return Ok(false);
}
}
Ok(true)
}
/// Diff trees, preflight all overwrite risks, write raw blobs, remove tracked paths and empty
/// directories, then atomically publish the index last. On I/O failure HEAD/refs remain unchanged;
/// worktree edits already completed are not rolled back. Filters/attributes are not applied.
/// Unrelated staged and unstaged edits survive a non-force switch. Force resets tracked paths,
/// but never overwrites/deletes an untracked file or follows a symlink directory.
pub(crate) fn write_tree_to_worktree(
repo: &gix::Repository,
old_tree: Option<gix::ObjectId>,
new_tree: gix::ObjectId,
force: bool,
) -> Result<Vec<String>> {
write_tree(repo, old_tree, new_tree, force, false)
}
/// Abort discards tracked changes without refusing dirty files. Paths no longer in the
/// index are untracked, even if HEAD once contained them. Preserve any such obstruction,
/// restoring the HEAD index but leaving a warning/local difference rather than losing bytes.
pub(crate) fn abort_to_tree(repo: &gix::Repository, tree: gix::ObjectId) -> Result<Vec<String>> {
write_tree(repo, None, tree, true, true)
}
fn write_tree(
repo: &gix::Repository,
old_tree: Option<gix::ObjectId>,
new_tree: gix::ObjectId,
force: bool,
preserve_obstructions: bool,
) -> Result<Vec<String>> {
let root = repo
.workdir()
.ok_or_else(|| error("checkout", "bare repository"))?;
let old = files(&tree_state(repo, old_tree)?)?;
let mut state = tree_state(repo, Some(new_tree))?;
let new = files(&state)?;
let index_path = repo.git_dir().join("index");
let existing = if index_path
.try_exists()
.map_err(|e| error("checkout index", e))?
{
Some(
gix::index::File::at(&index_path, repo.object_hash(), false, Default::default())
.map_err(|e| error("checkout index", e))?,
)
} else {
None
};
if existing.as_ref().is_some_and(|i| i.is_sparse()) {
return Err(error(
"checkout index",
"sparse indexes are unsupported; expand the index before checkout",
));
}
let indexed = existing
.as_ref()
.map(|i| files(i))
.transpose()?
.unwrap_or_default();
let mut affected: BTreeSet<String> = old
.keys()
.chain(new.keys())
.filter(|p| force || old.get(*p) != new.get(*p))
.cloned()
.collect();
if force {
affected.extend(indexed.keys().cloned());
} else {
// A staged addition can conflict structurally even when missing from the worktree.
// Include index ancestors/descendants of changed paths, so it cannot silently become a D/F conflict.
let changed = affected.clone();
affected.extend(
indexed
.keys()
.filter(|p| {
let prefix = format!("{p}/");
changed
.range(prefix.clone()..)
.next()
.is_some_and(|q| q.starts_with(&prefix))
|| Path::new(p)
.ancestors()
.skip(1)
.filter_map(Path::to_str)
.any(|q| changed.contains(q))
})
.cloned(),
);
}
let conflicts: BTreeSet<_> = existing
.as_ref()
.map(|i| {
i.entries()
.iter()
.filter(|e| e.stage_raw() != 0)
.map(|e| e.path(i).to_str_lossy().into_owned())
.collect()
})
.unwrap_or_default();
let tracked: BTreeSet<_> = old.keys().chain(indexed.keys()).cloned().collect();
let removed: BTreeSet<_> = affected
.iter()
.filter(|p| !new.get(*p).is_some_and(|e| regular(e.1)))
.cloned()
.collect();
let mut dirty = BTreeSet::new();
let mut blocked = BTreeSet::new();
let mut warnings = Vec::new();
// Validate paths in every source before touching anything, including index entries.
for path in &affected {
let dest = root.join(path);
// A symlink/file parent may be replaced only if tracked and scheduled for deletion.
let mut parent = Path::new(path).parent();
while let Some(p) = parent.filter(|p| !p.as_os_str().is_empty()) {
if let Some(m) = metadata(&root.join(p))? {
if m.file_type().is_symlink()
|| (!m.is_dir()
&& !removed.contains(p.to_str().unwrap_or(""))
&& new.get(path).is_some_and(|e| regular(e.1)))
{
blocked.insert(path.clone());
}
}
parent = p.parent();
}
// Inspect the actual worktree: a removed tracked ancestor may have been replaced
// by a directory containing untracked files. A real file parent yields None via
// metadata's NotADirectory handling, without hiding existing descendants.
let meta = metadata(&dest)?;
if let Some(m) = &meta {
if m.is_dir() && new.get(path).is_some_and(|e| regular(e.1)) {
if !removable_dir(root, &dest, &removed)? {
blocked.insert(path.clone());
}
} else if !m.is_dir() && !tracked.contains(path) {
blocked.insert(path.clone());
}
}
if !force {
if indexed.get(path) != old.get(path) && existing.is_some() {
dirty.insert(path.clone());
}
if conflicts.contains(path) {
dirty.insert(path.clone());
}
if let Some(expected) = old.get(path) {
if !clean(repo, &dest, *expected)? {
dirty.insert(path.clone());
}
}
}
}
if !blocked.is_empty() {
let paths = blocked.iter().cloned().collect::<Vec<_>>().join(", ");
if preserve_obstructions {
warnings.push(format!("abort preserved untracked/obstructing paths; HEAD index restored but worktree differs: {paths}"));
} else {
return Err(error(
"checkout untracked/obstructing paths (also protected with force)",
paths,
));
}
}
if !dirty.is_empty() {
return Err(error(
"checkout would overwrite uncommitted changes",
dirty.into_iter().collect::<Vec<_>>().join(", "),
));
}
// Read every required blob before beginning mutations (missing/corrupt objects must not partially checkout).
let mut blobs = BTreeMap::new();
for path in &affected {
if blocked.contains(path) {
continue;
}
if let Some((id, mode)) = new.get(path) {
if regular(*mode) {
blobs.insert(
path.clone(),
repo.find_blob(*id)
.map_err(|e| error("checkout blob", e))?
.data
.to_vec(),
);
} else {
warnings.push(format!(
"{path}: symlink/submodule unsupported; skipped worktree materialization"
));
}
}
}
// Remove old tracked leaves first, deepest first; never recursively delete a directory.
let mut deletions: Vec<_> = affected
.iter()
.filter(|p| tracked.contains(*p) && !blocked.contains(*p))
.collect();
deletions.sort_by_key(|p| std::cmp::Reverse(p.len()));
for path in deletions {
let dest = root.join(path);
// Ancestors may have been removed already (only affects structurally conflicting staged entries).
match fs::symlink_metadata(&dest) {
Ok(m) if !m.is_dir() => {
fs::remove_file(&dest).map_err(|e| error("checkout remove", e))?
}
Ok(_) => {}
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) => {}
Err(e) => return Err(error("checkout remove", e)),
}
prune_empty(root, dest.parent());
}
for (path, bytes) in blobs {
let dest = root.join(&path);
if dest.is_dir() {
remove_empty_dirs(&dest)?;
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| error("checkout mkdir", e))?;
}
fs::write(&dest, bytes).map_err(|e| error("checkout write", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = if new[&path].1 == Mode::FILE_EXECUTABLE {
0o755
} else {
0o644
};
fs::set_permissions(&dest, fs::Permissions::from_mode(mode))
.map_err(|e| error("checkout chmod", e))?;
}
}
#[cfg(not(unix))]
if affected
.iter()
.any(|p| new.get(p).is_some_and(|e| e.1 == Mode::FILE_EXECUTABLE))
{
warnings.push(
"executable bits preserved in index; filesystem chmod unavailable on this target"
.into(),
);
}
// Preserve unrelated staging, including staged additions, deletions, and conflict stages.
if let Some(index) = &existing {
state.remove_entries(|_, path, _| !affected.contains(path.to_str().unwrap_or("")));
for entry in index.entries() {
if !affected.contains(entry.path(index).to_str().unwrap_or("")) {
state.dangerously_push_entry(
entry.stat,
entry.id,
entry.flags,
entry.mode,
entry.path(index),
);
}
}
state.sort_entries();
state.remove_tree(); // Unrelated staging may differ from the target tree.
}
for (entry, path) in state.entries_mut_with_paths() {
if affected.contains(path.to_str().unwrap_or(""))
&& !blocked.contains(path.to_str().unwrap_or(""))
&& regular(entry.mode)
{
let stat = gix::index::fs::Metadata::from_path_no_follow(
&root.join(path.to_str().map_err(|e| error("checkout path", e))?),
)
.map_err(|e| error("checkout stat", e))?;
entry.stat = Stat::from_fs(&stat).map_err(|e| error("checkout stat", e))?;
}
}
gix::index::File::from_state(state, index_path)
.write(Default::default())
.map_err(|e| error("checkout index write", e))?;
Ok(warnings)
}
fn prune_empty(root: &Path, mut parent: Option<&Path>) {
while let Some(p) = parent.filter(|p| *p != root && p.starts_with(root)) {
if fs::remove_dir(p).is_err() {
break;
}
parent = p.parent();
}
}
fn remove_empty_dirs(path: &Path) -> Result<()> {
for child in fs::read_dir(path).map_err(|e| error("checkout directory", e))? {
let child = child.map_err(|e| error("checkout directory", e))?;
if child
.file_type()
.map_err(|e| error("checkout directory", e))?
.is_dir()
{
remove_empty_dirs(&child.path())?;
}
}
fs::remove_dir(path).map_err(|e| error("checkout directory not empty", e))
}
+8
View File
@@ -0,0 +1,8 @@
/// Read HEAD without mistaking an unborn branch for an I/O error.
fn head_info(repo: &gix::Repository) -> Result<HeadInfo> {
let head = repo.head().map_err(|e| error("HEAD", e))?;
Ok(HeadInfo { detached: head.is_detached(), unborn: head.is_unborn(),
branch: head.referent_name().map(|name| name.as_bstr().to_str_lossy().into_owned()),
id: head.id().map(|id| id.to_string()) })
}
+431
View File
@@ -0,0 +1,431 @@
//! Synchronous wasi:http backend. Response packs stream; replayable request bodies spool to the current directory.
//! No executor/block_on, worker threads, signal handling, or secret-bearing diagnostics.
use gix_error::ErrorExt;
use gix_transport::client::blocking_io::http::{GetResponse, Http, PostBodyDataKind, PostResponse};
use std::{
cell::RefCell,
fs::File,
io::{self, BufRead, BufReader, Cursor, Read, Seek, Write},
rc::Rc,
};
use wasi::http::{outgoing_handler, types::*};
type Shared = Rc<RefCell<Pending>>;
pub struct WasiHttp {
timeout: Option<u64>,
redirect: Rc<RefCell<Option<String>>>,
}
impl Default for WasiHttp {
fn default() -> Self {
Self {
timeout: None,
redirect: Default::default(),
}
}
}
fn err(text: impl Into<String>) -> io::Error {
io::Error::other(text.into())
}
/// The host injects credentials. Git accepts credential-free HTTP and HTTPS URLs.
pub fn clean_url(input: &str) -> io::Result<url::Url> {
let u = url::Url::parse(input).map_err(|_| err("invalid HTTP URL"))?;
if !matches!(u.scheme(), "http" | "https")
|| u.host_str().is_none()
|| !u.username().is_empty()
|| u.password().is_some()
|| u.query().is_some()
|| u.fragment().is_some()
{
return Err(err(
"use an HTTP or HTTPS repository URL without credentials, query, or fragment",
));
}
Ok(u)
}
fn status_error(status: u16, host: &str) -> io::Error {
// Other rather than PermissionDenied prevents external credential helpers.
err(format!("HTTP {status} from {host}"))
}
struct Incoming {
stream: Option<wasi::io::streams::InputStream>,
body: Option<IncomingBody>,
}
impl Read for Incoming {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
let Some(stream) = self.stream.as_ref() else {
return Ok(0);
};
loop {
match stream.blocking_read(buf.len().min(64 * 1024) as u64) {
Ok(bytes) if bytes.is_empty() => {
stream.subscribe().block();
continue;
}
Ok(bytes) => {
buf[..bytes.len()].copy_from_slice(&bytes);
return Ok(bytes.len());
}
Err(wasi::io::streams::StreamError::Closed) => {
self.stream.take();
if let Some(body) = self.body.take() {
let trailers = IncomingBody::finish(body);
trailers.subscribe().block();
match trailers.get() {
Some(Ok(Ok(_))) => {}
_ => return Err(err("HTTP response body failed")),
}
}
return Ok(0);
}
Err(_) => return Err(err("HTTP response read failed or timed out")),
}
}
}
}
impl Drop for Incoming {
fn drop(&mut self) {
self.stream.take();
self.body.take();
}
}
// State is shared only within one synchronous request, not across handlers/processes.
struct Pending {
timeout: Option<u64>,
url: String,
base: String,
headers: Vec<String>,
spool: Option<File>,
path: Option<std::path::PathBuf>,
writer_open: bool,
response_headers: Option<Cursor<Vec<u8>>>,
response: Option<BufReader<Box<dyn Read>>>,
redirect: Rc<RefCell<Option<String>>>,
}
impl Drop for Pending {
fn drop(&mut self) {
self.spool.take();
if let Some(path) = self.path.take() {
let _ = std::fs::remove_file(&path);
}
}
}
impl Pending {
fn start(&mut self) -> io::Result<()> {
if self.response.is_some() {
return Ok(());
}
if self.writer_open {
return Err(err(
"HTTP request body must be closed before reading response",
));
}
let mut target = url::Url::parse(&self.url).map_err(|_| err("invalid HTTP request URL"))?;
for attempt in 0..2 {
let response = self.send(&target)?;
let status = response.status();
if matches!(status, 301 | 302 | 303 | 307 | 308) {
if attempt == 1 {
return Err(err("HTTP redirect limit (one) exceeded"));
}
let locations = response.headers().get(&"location".to_string());
let location = locations
.first()
.and_then(|v| std::str::from_utf8(v).ok())
.ok_or_else(|| err("HTTP redirect has no valid Location"))?;
let next = target
.join(location)
.map_err(|_| err("invalid HTTP redirect"))?;
if !matches!(next.scheme(), "http" | "https")
|| (target.scheme() == "https" && next.scheme() != "https")
|| !next.username().is_empty()
|| next.password().is_some()
{
return Err(err("redirect requires an HTTPS URL without credentials"));
}
// Preserve the git endpoint suffix so subsequent POSTs use the new repository base.
let tail = self
.url
.strip_prefix(&self.base)
.ok_or_else(|| err("invalid HTTP base"))?;
let next_text = next.as_str();
let base = next_text
.strip_suffix(tail)
.ok_or_else(|| err("redirect changed the Git endpoint suffix"))?;
*self.redirect.borrow_mut() = Some(base.to_owned());
target = next;
continue;
}
if !(200..300).contains(&status) {
return Err(status_error(status, target.host_str().unwrap_or("unknown")));
}
let mut headers = Vec::new();
for (name, value) in response.headers().entries() {
headers.extend_from_slice(name.as_bytes());
headers.extend_from_slice(b": ");
headers.extend_from_slice(&value);
headers.push(b'\n');
}
let body = response
.consume()
.map_err(|_| err("could not consume HTTP response"))?;
let stream = body
.stream()
.map_err(|_| err("could not open HTTP response stream"))?;
self.response_headers = Some(Cursor::new(headers));
self.response = Some(BufReader::new(Box::new(Incoming {
stream: Some(stream),
body: Some(body),
})));
return Ok(());
}
Err(err("HTTP redirect failed"))
}
fn send(&mut self, target: &url::Url) -> io::Result<IncomingResponse> {
let fields = Fields::new();
for header in &self.headers {
let (name, value) = header
.split_once(':')
.ok_or_else(|| err("invalid HTTP header"))?;
// Never accept Authorization from Git config or URL.
if name.eq_ignore_ascii_case("authorization") {
continue;
}
fields
.append(&name.to_string(), &value.trim().as_bytes().to_vec())
.map_err(|_| err("invalid HTTP header"))?;
}
if let Some(file) = self.spool.as_ref() {
fields
.append(
"content-length",
&file.metadata()?.len().to_string().into_bytes(),
)
.map_err(|_| err("invalid content length"))?;
}
let request = OutgoingRequest::new(fields);
request
.set_method(&if self.spool.is_some() {
Method::Post
} else {
Method::Get
})
.map_err(|_| err("invalid HTTP method"))?;
let scheme = if target.scheme() == "https" {
Scheme::Https
} else {
Scheme::Http
};
request
.set_scheme(Some(&scheme))
.map_err(|_| err("invalid HTTP scheme"))?;
let authority = &target[url::Position::BeforeHost..url::Position::AfterPort];
request
.set_authority(Some(authority))
.map_err(|_| err("invalid HTTP authority"))?;
request
.set_path_with_query(Some(
&target[url::Position::BeforePath..url::Position::AfterQuery],
))
.map_err(|_| err("invalid HTTP path"))?;
let body = request
.body()
.map_err(|_| err("could not open HTTP request body"))?;
let options = RequestOptions::new();
options
.set_connect_timeout(Some(
self.timeout.map_or(120_000_000_000, |s| s * 1_000_000_000),
))
.map_err(|_| err("connect timeout unsupported"))?;
options
.set_first_byte_timeout(Some(
self.timeout.map_or(120_000_000_000, |s| s * 1_000_000_000),
))
.map_err(|_| err("read timeout unsupported"))?;
options
.set_between_bytes_timeout(Some(
self.timeout.map_or(120_000_000_000, |s| s * 1_000_000_000),
))
.map_err(|_| err("read timeout unsupported"))?;
let future = outgoing_handler::handle(request, Some(options))
.map_err(|_| err("HTTP connection failed"))?;
if let Some(file) = self.spool.as_mut() {
file.rewind()?;
let stream = body.write().map_err(|_| err("could not write HTTP body"))?;
let mut bytes = [0; 4096]; // WASI blocking_write_and_flush accepts at most 4096 bytes
loop {
let n = file.read(&mut bytes)?;
if n == 0 {
break;
}
stream
.blocking_write_and_flush(&bytes[..n])
.map_err(|_| err("HTTP request write failed or timed out"))?;
}
drop(stream);
}
OutgoingBody::finish(body, None).map_err(|_| err("HTTP request finish failed"))?;
future.subscribe().block();
future
.get()
.ok_or_else(|| err("HTTP response not ready"))?
.map_err(|_| err("HTTP response already consumed"))?
.map_err(|code| err(format!("HTTP request failed: {code:?}")))
}
}
pub struct Headers(Shared);
pub struct Response(Shared, Vec<u8>, usize);
pub struct PostBody(Shared);
impl Write for PostBody {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0
.borrow_mut()
.spool
.as_mut()
.ok_or_else(|| err("request closed"))?
.write(bytes)
}
fn flush(&mut self) -> io::Result<()> {
self.0
.borrow_mut()
.spool
.as_mut()
.ok_or_else(|| err("request closed"))?
.flush()
}
}
impl Drop for PostBody {
fn drop(&mut self) {
self.0.borrow_mut().writer_open = false;
}
}
impl Read for Headers {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let mut p = self.0.borrow_mut();
p.start()?;
p.response_headers
.as_mut()
.ok_or_else(|| err("missing headers"))?
.read(buf)
}
}
impl Read for Response {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let available = self.fill_buf()?;
let n = available.len().min(buf.len());
buf[..n].copy_from_slice(&available[..n]);
self.consume(n);
Ok(n)
}
}
impl BufRead for Response {
fn fill_buf(&mut self) -> io::Result<&[u8]> {
if self.2 == self.1.len() {
let mut p = self.0.borrow_mut();
p.start()?;
self.1.resize(16 * 1024, 0);
let n = p
.response
.as_mut()
.ok_or_else(|| err("missing response"))?
.read(&mut self.1)?;
self.1.truncate(n);
self.2 = 0;
}
Ok(&self.1[self.2..])
}
fn consume(&mut self, n: usize) {
self.2 = (self.2 + n).min(self.1.len());
}
}
impl WasiHttp {
fn pending(
&self,
url: &str,
base: &str,
headers: impl IntoIterator<Item = impl AsRef<str>>,
post: bool,
) -> io::Result<Shared> {
let (spool, path) = if post {
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let dir = ".";
let path = std::path::PathBuf::from(format!(
"{dir}/git-http-{}",
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
(
Some(
File::options()
.read(true)
.write(true)
.create_new(true)
.open(&path)?,
),
Some(path),
)
} else {
(None, None)
};
Ok(Rc::new(RefCell::new(Pending {
timeout: self.timeout,
url: url.into(),
base: base.into(),
headers: headers.into_iter().map(|s| s.as_ref().to_owned()).collect(),
spool,
path,
writer_open: post,
response_headers: None,
response: None,
redirect: self.redirect.clone(),
})))
}
}
impl Http for WasiHttp {
type Headers = BufReader<Headers>;
type ResponseBody = Response;
type PostBody = PostBody;
fn get(
&mut self,
url: &str,
base: &str,
headers: impl IntoIterator<Item = impl AsRef<str>>,
) -> gix_error::Result<GetResponse<Self::Headers, Response>> {
let p = self
.pending(url, base, headers, false)
.map_err(|e| e.raise())?;
p.borrow_mut().start().map_err(|e| e.raise())?;
Ok(GetResponse {
headers: BufReader::new(Headers(p.clone())),
body: Response(p, vec![], 0),
})
}
fn post(
&mut self,
url: &str,
base: &str,
headers: impl IntoIterator<Item = impl AsRef<str>>,
_: PostBodyDataKind,
) -> gix_error::Result<PostResponse<Self::Headers, Response, PostBody>> {
let p = self
.pending(url, base, headers, true)
.map_err(|e| e.raise())?;
Ok(PostResponse {
headers: BufReader::new(Headers(p.clone())),
body: Response(p.clone(), vec![], 0),
post_body: PostBody(p),
})
}
fn configure(&mut self, _: &dyn std::any::Any) -> gix_error::Result<()> {
Ok(())
}
fn redirected_base_url(&self) -> Option<String> {
self.redirect.borrow().clone()
}
}
+827
View File
@@ -0,0 +1,827 @@
//! Local Git operations adapted from the v2 gix plugin, without RPC or host imports.
#![allow(dead_code)]
mod branches;
mod changes;
mod checkout;
mod http;
mod merge;
mod network;
mod output;
mod push;
mod rebase;
mod remotes;
mod repo;
mod staging;
mod worktrees;
use gix::bstr::ByteSlice;
use repo::error;
#[derive(Debug)]
pub struct Error {
pub message: String,
}
impl Error {
pub fn new(_: i32, message: String) -> Self {
Self { message }
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for Error {}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(serde::Serialize, serde::Deserialize)]
pub struct HeadInfo {
detached: bool,
unborn: bool,
branch: Option<String>,
id: Option<String>,
}
include!("head.inc");
struct Identity {
name: Option<String>,
email: Option<String>,
}
fn identity(name: Option<String>, email: Option<String>) -> Result<Identity> {
let repo = gix::discover(".").map_err(|e| error("identity", e))?;
let config = repo.config_snapshot();
Ok(Identity {
name: name
.or_else(|| std::env::var("GIT_AUTHOR_NAME").ok())
.or_else(|| config.string("user.name").map(|s| s.to_string())),
email: email
.or_else(|| std::env::var("GIT_AUTHOR_EMAIL").ok())
.or_else(|| config.string("user.email").map(|s| s.to_string())),
})
}
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "git", disable_help_subcommand = true)]
pub struct Cli {
#[arg(short = 'C', global = true)]
directory: Option<std::path::PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Show {
revision: Option<String>,
},
Clone {
url: String,
path: Option<String>,
},
Fetch {
#[arg(default_value = "origin")]
remote: String,
},
Push {
#[arg(short = 'u', long)]
set_upstream: bool,
#[arg(short = 'f', long)]
force: bool,
#[arg(default_value = "origin")]
remote: String,
refspec: Option<String>,
},
Init {
#[arg(long)]
bare: bool,
#[arg(short = 'b', default_value = "main")]
initial_branch: String,
path: Option<String>,
},
Config {
#[arg(long)]
local: bool,
key: String,
value: Option<String>,
},
RevParse {
#[arg(long)]
show_toplevel: bool,
#[arg(long)]
git_dir: bool,
#[arg(long)]
abbrev_ref: bool,
revision: Option<String>,
},
Status {
#[arg(short, long)]
short: bool,
#[arg(long)]
porcelain: bool,
},
Add {
#[arg(short = 'A', long)]
all: bool,
paths: Vec<String>,
},
Rm {
#[arg(long)]
cached: bool,
paths: Vec<String>,
},
Commit {
#[arg(short = 'm')]
message: String,
#[arg(short, long)]
all: bool,
#[arg(long)]
allow_empty: bool,
},
Log {
#[arg(long)]
oneline: bool,
#[arg(short = 'n', default_value = "50")]
limit: usize,
revision: Option<String>,
},
Diff {
#[arg(long, alias = "staged")]
cached: bool,
#[arg(long)]
stat: bool,
},
Branch {
#[arg(short = 'd')]
delete: bool,
#[arg(short = 'D')]
force_delete: bool,
name: Option<String>,
start: Option<String>,
},
Switch {
#[arg(short = 'c')]
create: bool,
target: String,
},
Checkout {
#[arg(short = 'b')]
create: bool,
target: String,
},
Merge {
#[arg(long)]
ff_only: bool,
#[arg(long)]
no_ff: bool,
#[arg(long)]
no_commit: bool,
#[arg(long)]
abort: bool,
#[arg(short = 'm')]
message: Option<String>,
revision: Option<String>,
},
Rebase {
#[arg(long)]
abort: bool,
#[arg(long = "continue")]
resume: bool,
#[arg(long)]
skip: bool,
upstream: Option<String>,
},
Worktree {
#[command(subcommand)]
command: Worktree,
},
}
#[derive(Subcommand)]
enum Worktree {
List {
#[arg(long)]
porcelain: bool,
},
Add {
#[arg(short = 'b')]
branch: Option<String>,
path: String,
revision: Option<String>,
},
Remove {
#[arg(long)]
force: bool,
path: String,
},
}
fn required(value: Option<String>, what: &str) -> Result<String> {
value.ok_or_else(|| error(what, "argument required"))
}
pub fn run(cli: Cli) -> Result<i32> {
if let Some(dir) = cli.directory {
std::env::set_current_dir(dir).map_err(|e| error("chdir", e))?;
}
if let Command::Init {
bare,
initial_branch,
path,
} = cli.command
{
let path = path.unwrap_or_else(|| ".".into());
let repo = if bare {
gix::init_bare(&path)
} else {
gix::init(&path)
}
.map_err(|e| error("init", e))?;
std::fs::write(
repo.git_dir().join("HEAD"),
format!("ref: refs/heads/{initial_branch}\n"),
)
.map_err(|e| error("init HEAD", e))?;
println!(
"Initialized empty Git repository in {}/",
repo.git_dir()
.canonicalize()
.map_err(|e| error("init", e))?
.display()
);
return Ok(0);
}
if let Command::Clone { url, path } = cli.command {
let path = path.unwrap_or_else(|| {
url.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or("repo")
.trim_end_matches(".git")
.to_owned()
});
network::clone(&url, &path)?;
return Ok(0);
}
let discovered = gix::discover(".").map_err(|e| error("repository", e))?;
let root = discovered
.workdir()
.unwrap_or(discovered.git_dir())
.to_owned();
std::env::set_current_dir(&root).map_err(|e| error("chdir", e))?;
let path = root.to_string_lossy().into_owned();
let repository = repo::open(&path)?;
match cli.command {
Command::Config { key, value, .. } => {
let file = repository.common_dir().join("config");
if let Some(value) = value {
let mut config = gix::config::File::from_path_no_includes(
file.clone(),
gix::config::Source::Local,
)
.map_err(|e| error("config", e))?;
config
.set_raw_value(&key, value.as_bytes().as_bstr())
.map_err(|e| error("config", e))?;
let mut output = std::fs::File::create(file).map_err(|e| error("config", e))?;
config
.write_to(&mut output)
.map_err(|e| error("config", e))?;
} else if let Some(value) = repository.config_snapshot().string(&key) {
println!("{value}");
} else {
return Ok(1);
}
}
Command::RevParse {
show_toplevel,
git_dir,
abbrev_ref,
revision,
} => {
if show_toplevel {
println!("{}", root.display());
} else if git_dir {
println!(".git");
} else if abbrev_ref {
println!(
"{}",
head_info(&repository)?
.branch
.unwrap_or_else(|| "HEAD".into())
.trim_start_matches("refs/heads/")
);
} else {
println!(
"{}",
repository
.rev_parse_single(required(revision, "revision")?.as_str())
.map_err(|e| error("revision", e))?
);
}
}
Command::Add { all, mut paths } => {
let all = all || paths.iter().any(|p| p == ".");
paths.retain(|p| p != ".");
staging::add(
&repository,
staging::AddParams {
repo: path,
paths,
all: Some(all),
},
)?;
}
Command::Rm { cached, paths } => {
let result = staging::rm(
&repository,
staging::RmParams {
repo: path,
paths,
keep_file: Some(cached),
},
)?;
for path in result.paths {
println!("rm '{path}'");
}
}
Command::Commit {
message,
all,
allow_empty,
} => {
let before = head_info(&repository)?;
if !allow_empty && merge::pending_parent(&repository)?.is_none() {
let status = changes::status(&repository)?;
if status.staged.is_empty()
&& (!all || status.unstaged.is_empty())
&& status.conflicted.is_empty()
{
output::long_status(&status);
return Ok(1);
}
}
let result = staging::commit(
&repository,
staging::CommitParams {
repo: path.clone(),
message: format!("{message}\n"),
author_name: None,
author_email: None,
all: Some(all),
amend: None,
allow_empty: Some(allow_empty),
},
)?;
println!(
"[{}{} {}] {}",
before
.branch
.unwrap_or_else(|| "HEAD".into())
.trim_start_matches("refs/heads/"),
if before.unborn { " (root-commit)" } else { "" },
&result.id[..7],
message
);
let old = match before.id {
Some(id) => id,
None => repository
.write_object(&gix::objs::Tree { entries: vec![] })
.map_err(|e| error("commit summary", e))?
.to_string(),
};
output::commit_stat(&changes::diff(changes::DiffParams {
repo: path,
from: Some(old),
to: Some(result.id),
staged: None,
path: None,
context: None,
})?);
}
Command::Status { short, porcelain } => {
let mut status = changes::status(&repository)?;
let index = repository
.index_or_empty()
.map_err(|e| error("status index", e))?;
let tracked: Vec<_> = index
.entries()
.iter()
.map(|e| e.path(&index).to_str_lossy().into_owned())
.collect();
let mut untracked = std::collections::BTreeSet::new();
for path in status.untracked {
let collapsed = path
.match_indices('/')
.map(|(i, _)| &path[..=i])
.find(|prefix| !tracked.iter().any(|t| t.starts_with(*prefix)))
.unwrap_or(&path);
untracked.insert(collapsed.to_owned());
}
status.untracked = untracked.into_iter().collect();
if !short && !porcelain {
output::long_status(&status);
return Ok(0);
}
let mut rows = std::collections::BTreeMap::new();
for c in status.staged {
rows.entry(c.path).or_insert([' '; 2])[0] = code(&c.status);
}
for c in status.unstaged {
rows.entry(c.path).or_insert([' '; 2])[1] = code(&c.status);
}
for p in status.untracked {
rows.insert(p, ['?', '?']);
}
for p in status.conflicted {
rows.insert(p, ['U', 'U']);
}
for (p, c) in rows {
println!("{}{} {p}", c[0], c[1]);
}
}
Command::Diff { cached, stat } => {
let diffs = changes::diff(changes::DiffParams {
repo: path,
from: None,
to: None,
staged: Some(cached),
path: None,
context: None,
})?;
if stat {
output::stat(&diffs, false);
} else {
for diff in diffs {
print!("{}", diff.patch);
}
}
}
Command::Log {
oneline,
limit,
revision,
} => {
let commit = repository
.rev_parse_single(revision.as_deref().unwrap_or("HEAD"))
.map_err(|e| error("log", e))?
.object()
.map_err(|e| error("log", e))?
.peel_to_commit()
.map_err(|e| error("log", e))?;
let mut first = true;
for info in commit
.ancestors()
.use_commit_graph(false)
.sorting(gix::revision::walk::Sorting::ByCommitTime(
Default::default(),
))
.all()
.map_err(|e| error("log", e))?
.take(limit)
{
let c = info
.map_err(|e| error("log", e))?
.object()
.map_err(|e| error("log", e))?;
let decoded = c.decode().map_err(|e| error("log", e))?;
let message = decoded.message.to_str_lossy();
if oneline {
println!(
"{} {}",
&c.id.to_string()[..7],
message.lines().next().unwrap_or("")
);
} else {
if !first {
println!();
}
output::commit(&c)?;
first = false;
}
}
}
Command::Branch {
delete,
force_delete,
name,
start,
} => {
if let Some(name) = name {
if delete || force_delete {
branches::delete(
&repository,
branches::BranchDeleteParams {
repo: path,
name,
force: Some(force_delete),
},
)?;
} else {
branches::create(
&repository,
branches::BranchCreateParams {
repo: path,
name,
from: start,
checkout: Some(false),
},
)?;
}
} else {
let head = head_info(&repository)?;
for branch in repository
.references()
.map_err(|e| error("branch", e))?
.local_branches()
.map_err(|e| error("branch", e))?
{
let branch = branch.map_err(|e| error("branch", e))?;
println!(
"{} {}",
if head.branch.as_deref()
== Some(branch.name().as_bstr().to_str_lossy().as_ref())
{
"*"
} else {
" "
},
branch.name().shorten()
);
}
}
}
Command::Switch { create, target } | Command::Checkout { create, target } => {
branches::checkout(
&repository,
branches::CheckoutParams {
repo: path,
target,
create: Some(create),
force: None,
},
)?;
}
Command::Merge {
ff_only,
no_ff,
no_commit,
abort,
message,
revision,
} => {
let old = head_info(&repository)?.id;
let message =
message.or_else(|| revision.as_ref().map(|r| format!("Merge branch '{r}'\n")));
let r = if abort {
merge::abort(&repository)?
} else {
merge::merge(
&repository,
merge::MergeParams {
repo: path.clone(),
rev: required(revision, "merge")?,
message,
ff: Some(if ff_only {
merge::FastForward::Only
} else if no_ff {
merge::FastForward::Never
} else {
merge::FastForward::Allow
}),
no_commit: Some(no_commit),
},
)?
};
if r.already_up_to_date {
println!("Already up to date.");
}
output::conflicts(&r.conflicts, true);
if !r.conflicts.is_empty() {
return Ok(1);
}
if !abort && !r.already_up_to_date && !r.pending {
let new = head_info(&repo::open(&path)?)?.id.unwrap();
if let Some(old) = old {
if r.fast_forward {
println!("Updating {}..{}\nFast-forward", &old[..7], &new[..7]);
} else {
println!("Merge made by the 'ort' strategy.");
}
output::stat(
&changes::diff(changes::DiffParams {
repo: path,
from: Some(old),
to: Some(new),
staged: None,
path: None,
context: None,
})?,
true,
);
}
}
}
Command::Rebase {
abort,
resume,
skip,
upstream,
} => {
let r = if abort {
rebase::abort(&repository)?
} else if resume || skip {
rebase::resume(&repository, skip)?
} else {
rebase::start(
&repository,
rebase::RebaseParams {
repo: path.clone(),
onto: Some(required(upstream, "rebase")?),
branch: None,
},
)?
};
if let Some(id) = &r.resolved {
let commit = repository
.find_commit(
gix::ObjectId::from_hex(id.as_bytes())
.map_err(|e| error("rebase output", e))?,
)
.map_err(|e| error("rebase output", e))?;
let decoded = commit.decode().map_err(|e| error("rebase output", e))?;
println!(
"[detached HEAD {}] {}",
&id[..7],
decoded.message.to_str_lossy().lines().next().unwrap_or("")
);
output::commit_stat(&changes::diff(changes::DiffParams {
repo: path,
from: Some(decoded.parents[0].to_string()),
to: Some(id.clone()),
staged: None,
path: None,
context: None,
})?);
}
output::conflicts(&r.conflicts, false);
if !r.conflicts.is_empty() {
return Ok(1);
}
}
Command::Worktree { command } => match command {
Worktree::List { porcelain } => {
let common = repository
.common_dir()
.canonicalize()
.map_err(|e| error("worktree list", e))?;
let mut paths = vec![
common
.parent()
.ok_or_else(|| error("worktree list", "no worktree"))?
.to_owned(),
];
if let Ok(admins) = std::fs::read_dir(common.join("worktrees")) {
let mut linked = Vec::new();
for admin in admins {
let admin = admin.map_err(|e| error("worktree list", e))?;
let gitfile = std::fs::read_to_string(admin.path().join("gitdir"))
.map_err(|e| error("worktree list", e))?;
if let Some(parent) = std::path::Path::new(gitfile.trim()).parent() {
linked.push(parent.to_owned());
}
}
linked.sort();
paths.extend(linked);
}
let width = paths
.iter()
.map(|p| p.to_string_lossy().len())
.max()
.unwrap_or(0)
+ 2;
for p in paths {
let linked = repo::open(&p.to_string_lossy())?;
let head = head_info(&linked)?;
let id = head.id.unwrap_or_else(|| "0".repeat(40));
if porcelain {
println!("worktree {}\nHEAD {}", p.display(), id);
if let Some(branch) = head.branch {
println!("branch {branch}");
} else {
println!("detached");
}
println!();
} else {
let branch = head
.branch
.map(|b| format!("[{}]", b.trim_start_matches("refs/heads/")))
.unwrap_or_else(|| "(detached HEAD)".into());
println!("{:width$}{} {}", p.display().to_string(), &id[..7], branch);
}
}
}
Worktree::Add {
branch,
path: target,
revision,
} => {
let branch = branch.unwrap_or_else(|| {
std::path::Path::new(&target)
.file_name()
.unwrap()
.to_string_lossy()
.into_owned()
});
let info = worktrees::add(
&repository,
worktrees::AddParams {
repo: path,
path: std::path::absolute(target)
.map_err(|e| error("worktree path", e))?
.to_string_lossy()
.into_owned(),
branch,
from: revision,
adopt: None,
},
)?;
let linked = repo::open(&info.path)?;
let commit = linked
.head_commit()
.map_err(|e| error("worktree HEAD", e))?;
let decoded = commit.decode().map_err(|e| error("worktree HEAD", e))?;
println!(
"HEAD is now at {} {}",
&commit.id.to_string()[..7],
decoded.message.to_str_lossy().lines().next().unwrap_or("")
);
}
Worktree::Remove { force, path } => {
let target = std::path::absolute(path)
.map_err(|e| error("worktree remove", e))?
.to_string_lossy()
.into_owned();
worktrees::remove(
&repo::open(&target)?,
worktrees::RemoveParams {
repo: target,
force: Some(force),
},
)?;
}
},
Command::Show { revision } => {
let object = repository
.rev_parse_single(revision.as_deref().unwrap_or("HEAD"))
.map_err(|e| error("show", e))?
.object()
.map_err(|e| error("show", e))?;
if object.kind == gix::objs::Kind::Blob {
use std::io::Write;
std::io::stdout()
.write_all(&object.data)
.map_err(|e| error("show", e))?;
} else {
let commit = object.peel_to_commit().map_err(|e| error("show", e))?;
output::commit(&commit)?;
println!();
let old = match commit.parent_ids().next() {
Some(id) => id.to_string(),
None => repository
.write_object(&gix::objs::Tree { entries: vec![] })
.map_err(|e| error("show", e))?
.to_string(),
};
for diff in changes::diff(changes::DiffParams {
repo: path,
from: Some(old),
to: Some(commit.id.to_string()),
staged: None,
path: None,
context: None,
})? {
print!("{}", diff.patch);
}
}
}
Command::Fetch { remote } => network::fetch(&path, &remote)?,
Command::Push {
set_upstream,
force,
remote,
refspec,
} => {
push::push(push::PushParams {
repo: path,
remote: Some(remote),
branch: None,
refspec,
force: Some(force),
set_upstream: Some(set_upstream),
delete: None,
})?;
}
Command::Init { .. } | Command::Clone { .. } => unreachable!(),
}
Ok(0)
}
fn code(k: &changes::Kind) -> char {
match k {
changes::Kind::Added => 'A',
changes::Kind::Modified => 'M',
changes::Kind::Deleted => 'D',
changes::Kind::Renamed => 'R',
}
}
+39
View File
@@ -0,0 +1,39 @@
//! Parse Git CLI arguments and return Git-compatible usage and operation exit codes.
use clap::{Parser, error::ErrorKind};
fn main() {
let cli = match git::Cli::try_parse() {
Ok(cli) => cli,
Err(error) => {
let code = if !error.use_stderr() {
0
} else {
match error.kind() {
ErrorKind::InvalidSubcommand | ErrorKind::MissingSubcommand => 1,
_ => 129,
}
};
let _ = error.print();
exit(code);
}
};
let code = match git::run(cli) {
Ok(code) => code,
Err(error) => {
eprintln!("fatal: {error}");
128
}
};
exit(code);
}
fn exit(code: i32) -> ! {
#[cfg(target_os = "wasi")]
{
use std::io::Write;
let _ = std::io::stdout().flush();
let _ = std::io::stderr().flush();
wasi::cli::exit::exit_with_code(code as u8);
unreachable!("WASI exit returned");
}
#[cfg(not(target_os = "wasi"))]
std::process::exit(code)
}
+407
View File
@@ -0,0 +1,407 @@
//! Merge workflow around gix's recursive commit/tree merge. Callers hold .git lock.
use crate::Result;
use crate::{branches, checkout, repo::error, staging};
use gix::bstr::ByteSlice;
use std::{collections::BTreeSet, fs};
/// Fast-forward policy.
#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FastForward {
/// Advance HEAD directly when possible, otherwise perform a three-way merge.
Allow,
/// Refuse divergent histories instead of making a merge commit.
Only,
/// Make a two-parent commit even when a fast-forward is possible.
Never,
}
/// Merge a single commit into HEAD.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct MergeParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Revision to merge.
pub rev: String,
/// Merge message; defaults to Merge '<rev>'.
pub message: Option<String>,
/// Fast-forward policy, default allow.
pub ff: Option<FastForward>,
/// Stop before creating a merge commit (does not stop a fast-forward).
pub no_commit: Option<bool>,
}
/// Resume a merge after git.add resolves every conflict.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ContinueParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Override MERGE_MSG.
pub message: Option<String>,
}
/// Merge outcome.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct MergeResult {
/// HEAD after the operation.
pub head: crate::HeadInfo,
/// True when HEAD was advanced without creating a merge commit.
pub fast_forward: bool,
/// True when the requested commit was already reachable.
pub already_up_to_date: bool,
/// True when merge_continue or merge_abort is needed.
pub pending: bool,
/// Sorted unresolved index paths; stage 1=base, 2=ours, 3=theirs.
pub conflicts: Vec<String>,
/// Checkout feature limitations.
pub warnings: Vec<String>,
}
fn result(
repo: &gix::Repository,
fast_forward: bool,
already: bool,
pending: bool,
conflicts: Vec<String>,
warnings: Vec<String>,
) -> Result<MergeResult> {
Ok(MergeResult {
head: crate::head_info(repo)?,
fast_forward,
already_up_to_date: already,
pending,
conflicts,
warnings,
})
}
pub fn ensure_idle(repo: &gix::Repository) -> Result<()> {
for name in [
"MERGE_HEAD",
"CHERRY_PICK_HEAD",
"REVERT_HEAD",
"rebase-merge",
"rebase-apply",
] {
if repo.git_dir().join(name).exists() {
return Err(error(
"merge",
format!("{name} exists; finish or abort the current operation first"),
));
}
}
Ok(())
}
fn tree(repo: &gix::Repository, id: gix::ObjectId) -> Result<gix::ObjectId> {
Ok(repo
.find_commit(id)
.map_err(|e| error("merge commit", e))?
.tree_id()
.map_err(|e| error("merge tree", e))?
.detach())
}
fn reachable(repo: &gix::Repository, tip: gix::ObjectId, ancestor: gix::ObjectId) -> Result<bool> {
let walk = repo
.find_commit(tip)
.map_err(|e| error("merge ancestry", e))?
.ancestors()
.use_commit_graph(false)
.all()
.map_err(|e| error("merge ancestry", e))?;
for entry in walk {
if entry.map_err(|e| error("merge ancestry", e))?.id == ancestor {
return Ok(true);
}
}
Ok(false)
}
pub(crate) fn clean(repo: &gix::Repository) -> Result<()> {
check_index_lock(repo)?;
let s = crate::changes::status(repo)?;
let paths: BTreeSet<_> = s
.staged
.into_iter()
.chain(s.unstaged)
.flat_map(|c| std::iter::once(c.path).chain(c.old_path))
.chain(s.conflicted)
.collect();
if !paths.is_empty() {
return Err(error(
"merge dirty worktree/index",
paths.into_iter().collect::<Vec<_>>().join(", "),
));
}
// Untracked files may remain; the checkout engine preflights all obstructions, including ignored files.
Ok(())
}
fn validate_message(message: &str) -> Result<()> {
if message.trim().is_empty() || message.contains('\0') {
return Err(error(
"merge",
"message must be nonblank and contain no NUL",
));
}
Ok(())
}
fn save(
repo: &gix::Repository,
ours: gix::ObjectId,
theirs: gix::ObjectId,
message: &str,
ff: FastForward,
) -> Result<()> {
for (name, bytes) in [
("ORIG_HEAD", format!("{ours}\n")),
("MERGE_MSG", message.to_owned()),
(
"MERGE_MODE",
if ff == FastForward::Never {
"no-ff".into()
} else {
String::new()
},
),
("MERGE_HEAD", format!("{theirs}\n")),
] {
fs::write(repo.git_dir().join(name), bytes).map_err(|e| error("merge state", e))?;
}
Ok(())
}
pub fn clear(repo: &gix::Repository) -> Result<()> {
for name in ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE"] {
match fs::remove_file(repo.git_dir().join(name)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(error("merge cleanup", e)),
}
}
Ok(())
}
pub fn pending_parent(repo: &gix::Repository) -> Result<Option<gix::ObjectId>> {
let bytes = match fs::read_to_string(repo.git_dir().join("MERGE_HEAD")) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(error("merge state", e)),
};
let ids: Vec<_> = bytes.lines().collect();
if ids.len() != 1 {
return Err(error(
"merge state",
"expected one MERGE_HEAD; octopus merges unsupported",
));
}
let id = gix::ObjectId::from_hex(ids[0].as_bytes()).map_err(|e| error("MERGE_HEAD", e))?;
repo.find_commit(id).map_err(|e| error("MERGE_HEAD", e))?;
Ok(Some(id))
}
fn advance(
repo: &gix::Repository,
old: Option<gix::ObjectId>,
new: gix::ObjectId,
) -> Result<Vec<String>> {
use gix::refs::{
Target,
transaction::{Change, PreviousValue, RefEdit},
};
// Identity checked by caller before any worktree mutation.
branches::edit_refs(
repo,
vec![RefEdit {
name: "HEAD".try_into().map_err(|e| error("merge HEAD", e))?,
deref: true,
change: Change::Update {
expected: old
.map(|id| PreviousValue::MustExistAndMatch(Target::Object(id)))
.unwrap_or(PreviousValue::MustNotExist),
new: Target::Object(new),
log: Default::default(),
},
}],
)
}
pub fn merge(repo: &gix::Repository, p: MergeParams) -> Result<MergeResult> {
// In-memory policy only; never rewrite the person's .git/config. No mmap,
// renormalization/eol filters, or external merge-driver processes on WASI.
let local = merge_repository(repo)?;
let repo = &local;
ensure_idle(repo)?;
clean(repo)?;
let theirs = branches::resolve(repo, &p.rev)?;
merge_inner(repo, p, theirs)
}
/// Object-only merge policy shared with the rebase sequencer. Never rewrites config.
pub(crate) fn merge_repository(repo: &gix::Repository) -> Result<gix::Repository> {
let mut local = repo.clone();
{
let mut config = local.config_snapshot_mut();
config
.set_raw_value("core.commitGraph", "false")
.map_err(|e| error("merge config", e))?;
config
.set_raw_value("merge.renormalize", "false")
.map_err(|e| error("merge config", e))?;
config
.set_raw_value("merge.default", "text")
.map_err(|e| error("merge config", e))?;
let names: Vec<_> = config
.sections_by_name("merge")
.into_iter()
.flatten()
.filter_map(|s| s.header().subsection_name().map(ToOwned::to_owned))
.collect();
for name in names {
while config.remove_section("merge", name.as_bstr()).is_some() {}
}
config.commit().map_err(|e| error("merge config", e))?;
}
Ok(local)
}
fn merge_inner(
repo: &gix::Repository,
p: MergeParams,
theirs: gix::ObjectId,
) -> Result<MergeResult> {
let ours = repo
.head()
.map_err(|e| error("merge HEAD", e))?
.id()
.map(|id| id.detach());
let ff = p.ff.unwrap_or(FastForward::Allow);
let their_tree = tree(repo, theirs)?;
if let Some(ours) = ours {
if reachable(repo, ours, theirs)? {
return result(repo, false, true, false, vec![], vec![]);
}
}
let can_ff = match ours {
Some(ours) => reachable(repo, theirs, ours)?,
None => true,
};
if ff == FastForward::Only && !can_ff {
return Err(error(
"merge ff-only",
"not possible: histories have diverged",
));
}
if can_ff && (ff != FastForward::Never || ours.is_none()) {
repo.committer()
.transpose()
.map_err(|e| error("merge reflog identity", e))?;
let mut warnings = checkout::write_tree_to_worktree(
repo,
ours.map(|id| tree(repo, id)).transpose()?,
their_tree,
false,
)?;
if let Some(id) = ours {
fs::write(repo.git_dir().join("ORIG_HEAD"), format!("{id}\n"))
.map_err(|e| error("merge ORIG_HEAD", e))?;
}
warnings.extend(advance(repo, ours, theirs)?);
return result(repo, true, false, false, vec![], warnings);
}
let ours = ours.ok_or_else(|| error("merge", "unborn HEAD"))?;
let message = p
.message
.unwrap_or_else(|| format!("Merge '{}'\n", p.rev.replace(['\n', '\r'], " ")));
validate_message(&message)?;
// Resolve identity before changing files. no_commit/conflicts may be continued after configuring identity.
if !p.no_commit.unwrap_or(false) {
staging::signature(None, None)?;
}
let labels = gix::merge::blob::builtin_driver::text::Labels {
ancestor: None,
current: Some("ours".into()),
other: Some("theirs".into()),
};
let mut outcome = repo
.merge_commits(
ours,
theirs,
labels,
repo.tree_merge_options()
.map_err(|e| error("merge options", e))?
.into(),
)
.map_err(|e| error("merge three-way", e))?
.tree_merge;
let unresolved = outcome.has_unresolved_conflicts(Default::default());
let merged = outcome
.tree
.write()
.map_err(|e| error("merge tree write", e))?
.detach();
let mut state = gix::index::State::from_tree(&merged, &repo.objects, Default::default())
.map_err(|e| error("merge index", e))?;
outcome.index_changed_after_applying_conflicts(
&mut state,
Default::default(),
gix::merge::tree::apply_index_entries::RemovalMode::Prune,
);
state.sort_entries();
let conflicts: BTreeSet<_> = state
.entries()
.iter()
.filter(|e| e.stage_raw() != 0)
.map(|e| e.path(&state).to_str_lossy().into_owned())
.collect();
if unresolved && conflicts.is_empty() {
return Err(error(
"merge",
"gix reported a conflict without index stages; cannot safely publish this merge",
));
}
// The result tree contains gix-generated marker blobs and evasively renamed paths.
let warnings = checkout::write_tree_to_worktree(repo, Some(tree(repo, ours)?), merged, false)?;
save(repo, ours, theirs, &message, ff)?;
staging::publish(repo, state)?;
if !conflicts.is_empty() || p.no_commit.unwrap_or(false) {
return result(
repo,
false,
false,
true,
conflicts.into_iter().collect(),
warnings,
);
}
continue_merge(
repo,
ContinueParams {
repo: p.repo,
message: Some(message),
},
)?;
result(repo, false, false, false, vec![], warnings)
}
pub fn continue_merge(repo: &gix::Repository, p: ContinueParams) -> Result<staging::CommitResult> {
pending_parent(repo)?.ok_or_else(|| error("merge continue", "no merge in progress"))?;
let message = match p.message {
Some(m) => m,
None => fs::read_to_string(repo.git_dir().join("MERGE_MSG"))
.map_err(|e| error("MERGE_MSG", e))?,
};
staging::commit(
repo,
staging::CommitParams {
repo: p.repo,
message,
author_name: None,
author_email: None,
all: None,
amend: None,
allow_empty: Some(true),
},
)
}
fn check_index_lock(repo: &gix::Repository) -> Result<()> {
if repo.git_dir().join("index.lock").exists() {
return Err(error("merge index", "index.lock already exists"));
}
Ok(())
}
pub fn abort(repo: &gix::Repository) -> Result<MergeResult> {
check_index_lock(repo)?;
if !repo.git_dir().join("MERGE_HEAD").exists() {
return Err(error("merge abort", "no merge in progress"));
}
let head = branches::resolve(repo, "HEAD")?;
let warnings = checkout::abort_to_tree(repo, tree(repo, head)?)?;
clear(repo)?;
result(repo, false, false, false, vec![], warnings)
}
+241
View File
@@ -0,0 +1,241 @@
//! Anonymous HTTP and HTTPS clone/fetch through standard wasi:http.
use crate::{
Result,
http::{WasiHttp, clean_url},
remotes,
repo::{self, error},
};
use gix::bstr::ByteSlice;
use std::{collections::BTreeSet, sync::atomic::AtomicBool};
struct FetchResult {
remote: String,
advertised_refs: usize,
received_pack: bool,
pruned: Vec<String>,
copied_objects: Option<usize>,
updated_refs: Option<Vec<String>>,
}
pub(crate) fn fetch(path: &str, remote: &str) -> Result<()> {
let mut repo = repo::open(path)?;
repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable;
perform(&repo, remote, None, false)?;
Ok(())
}
pub(crate) fn clone(url: &str, path: &str) -> Result<()> {
clean_url(url).map_err(|e| error("clone URL", e))?;
let path = std::path::absolute(path).map_err(|e| error("clone path", e))?;
std::fs::create_dir(&path).map_err(|e| error("clone destination", e))?;
gix::init(&path).map_err(|e| error("clone init", e))?;
let name = path.to_string_lossy();
let repo = repo::open(&name)?;
remotes::set(&repo, "origin", url, true)?;
let mut repo = repo::open(&name)?;
repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable;
let fetched = perform(&repo, "origin", None, false)?;
let branch = fetched
.default_branch
.ok_or_else(|| error("clone", "server HEAD does not name a branch"))?;
if !fetched.empty {
crate::branches::create(
&repo,
crate::branches::BranchCreateParams {
repo: name.to_string(),
name: branch.clone(),
from: Some(format!("refs/remotes/origin/{branch}")),
checkout: Some(true),
},
)?;
}
remotes::upstream(&repo, &branch)?;
Ok(())
}
struct Fetched {
summary: FetchResult,
default_branch: Option<String>,
heads: BTreeSet<String>,
empty: bool,
}
fn perform(
repo: &gix::Repository,
remote_name: &str,
depth: Option<u32>,
prune: bool,
) -> Result<Fetched> {
remotes::validate_name(remote_name)?;
let remote = repo
.find_remote(remote_name)
.map_err(|_| error("fetch", "configured remote not found"))?;
let url = remote
.url(gix::remote::Direction::Fetch)
.ok_or_else(|| error("fetch", "remote has no fetch URL"))?
.to_bstring()
.to_str_lossy()
.into_owned();
clean_url(&url).map_err(|e| error("fetch URL", e))?;
if prune {
let expected = format!("+refs/heads/*:refs/remotes/{remote_name}/*");
let specs = remote.refspecs(gix::remote::Direction::Fetch);
if specs.len() != 1 || specs[0].to_ref().to_bstring() != expected.as_bytes() {
return Err(error(
"prune",
"prune requires the standard +refs/heads/*:refs/remotes/<remote>/* refspec",
));
}
}
let transport: Box<dyn gix_transport::client::blocking_io::Transport> = {
let transport_url = gix::url::parse(url.as_bytes().as_bstr())
.map_err(|_| error("fetch", "invalid HTTPS URL"))?;
Box::new(
gix_transport::client::blocking_io::http::Transport::new_http(
WasiHttp::default(),
transport_url,
gix_transport::Protocol::V2,
false,
),
)
};
let mut options = gix::remote::ref_map::Options::default();
options.extra_refspecs.push(
gix::refspec::parse("HEAD".into(), gix::refspec::parse::Operation::Fetch)
.map_err(|e| error("fetch HEAD", e))?
.to_owned(),
);
let mut prepared = remote
.to_connection_with_transport(transport)
.prepare_fetch(gix::progress::Discard, options)
.map_err(|e| error("fetch handshake", format!("{e:#}")))?;
let refs = &prepared.ref_map().remote_refs;
let mut heads = BTreeSet::new();
let mut default_branch = None;
for reference in refs {
let (name, _, _) = reference.unpack();
if let Some(branch) = name.to_str_lossy().strip_prefix("refs/heads/") {
heads.insert(branch.to_owned());
}
match reference {
gix::protocol::handshake::Ref::Symbolic {
full_ref_name,
target,
..
}
| gix::protocol::handshake::Ref::Unborn {
full_ref_name,
target,
} if full_ref_name.as_slice() == b"HEAD" => {
default_branch = target
.to_str_lossy()
.strip_prefix("refs/heads/")
.map(str::to_owned);
}
_ => {}
}
}
let advertised_refs = refs.len();
let empty = heads.is_empty() && refs.iter().all(|r| r.unpack().1.is_none());
if let Some(depth) = depth {
prepared = prepared.with_shallow(gix::remote::fetch::Shallow::DepthAtRemote(
depth
.try_into()
.map_err(|_| error("fetch", "depth must be positive"))?,
));
}
let outcome = prepared
.receive(gix::progress::Discard, &AtomicBool::new(false))
.map_err(|e| error("fetch", format!("{e:#}")))?;
let received_pack = matches!(outcome.status, gix::remote::fetch::Status::Change { .. });
let updates = match &outcome.status {
gix::remote::fetch::Status::Change { update_refs, .. }
| gix::remote::fetch::Status::NoPackReceived { update_refs, .. } => update_refs,
};
use gix::remote::fetch::refs::update::Mode;
for update in &updates.updates {
if !matches!(
update.mode,
Mode::NoChangeNeeded
| Mode::FastForward
| Mode::Forced
| Mode::New
| Mode::ImplicitTagNotSentByRemote
) {
return Err(error(
"fetch refs (other refs may already be updated)",
&update.mode,
));
}
}
if let Some(branch) = default_branch.as_deref().filter(|b| heads.contains(*b)) {
use gix::refs::{
Target,
transaction::{Change, PreviousValue, RefEdit},
};
repo.edit_references_as(
[RefEdit {
name: format!("refs/remotes/{remote_name}/HEAD")
.as_str()
.try_into()
.map_err(|e| error("remote HEAD", e))?,
deref: false,
change: Change::Update {
log: Default::default(),
expected: PreviousValue::Any,
new: Target::Symbolic(
format!("refs/remotes/{remote_name}/{branch}")
.as_str()
.try_into()
.map_err(|e| error("remote HEAD target", e))?,
),
},
}],
None,
)
.map_err(|e| error("remote HEAD", e))?;
}
let pruned = if prune {
prune_refs(repo, remote_name, &heads)?
} else {
vec![]
};
Ok(Fetched {
summary: FetchResult {
remote: remote_name.into(),
advertised_refs,
received_pack,
pruned,
copied_objects: None,
updated_refs: None,
},
default_branch,
heads,
empty,
})
}
/// Delete only stale refs in this remote's namespace. Symbolic HEAD is not a branch.
pub fn prune_refs(
repo: &gix::Repository,
remote: &str,
heads: &BTreeSet<String>,
) -> Result<Vec<String>> {
remotes::validate_name(remote)?;
let prefix = format!("refs/remotes/{remote}/");
let refs = repo.references().map_err(|e| error("prune refs", e))?;
let mut stale = Vec::new();
for reference in refs
.prefixed(prefix.as_str())
.map_err(|e| error("prune refs", e))?
{
let reference = reference.map_err(|e| error("prune ref", e))?;
let name = reference.name().as_bstr().to_str_lossy().into_owned();
if let Some(short) = name.strip_prefix(&prefix) {
if short != "HEAD" && !heads.contains(short) {
stale.push(name);
}
}
}
for name in &stale {
repo.find_reference(name.as_str())
.map_err(|e| error("prune ref", e))?
.delete()
.map_err(|e| error("prune delete", e))?;
}
Ok(stale)
}
+177
View File
@@ -0,0 +1,177 @@
//! Git's text summaries for small, textual diffs.
use crate::changes::{FileDiff, Kind, Status};
use gix::bstr::ByteSlice;
pub(crate) fn commit(commit: &gix::Commit<'_>) -> crate::Result<()> {
let decoded = commit
.decode()
.map_err(|e| crate::repo::error("commit output", e))?;
println!("commit {}", commit.id);
if decoded.parents.len() > 1 {
println!(
"Merge: {}",
decoded
.parents
.iter()
.map(|id| id.to_string()[..7].to_owned())
.collect::<Vec<_>>()
.join(" ")
);
}
let author = commit
.author()
.map_err(|e| crate::repo::error("commit author", e))?;
let time = author
.time()
.map_err(|e| crate::repo::error("commit date", e))?;
println!(
"Author: {} <{}>\nDate: {}\n\n {}",
author.name,
author.email,
time.format_or_unix(gix::date::time::format::DEFAULT),
decoded
.message
.to_str_lossy()
.trim_end()
.replace('\n', "\n ")
);
Ok(())
}
pub(crate) fn long_status(s: &Status) {
let branch = s
.head
.branch
.as_deref()
.unwrap_or("HEAD")
.trim_start_matches("refs/heads/");
println!("On branch {branch}");
if s.head.unborn {
println!("\nNo commits yet\n");
}
if !s.staged.is_empty() {
println!("Changes to be committed:");
println!(
" (use \"git {} <file>...\" to unstage)",
if s.head.unborn {
"rm --cached"
} else {
"restore --staged"
}
);
for c in &s.staged {
println!("\t{:12}{}", label(&c.status), c.path);
}
println!();
}
if !s.unstaged.is_empty() {
println!("Changes not staged for commit:");
println!(" (use \"git add <file>...\" to update what will be committed)");
println!(" (use \"git restore <file>...\" to discard changes in working directory)");
for c in &s.unstaged {
println!("\t{:12}{}", label(&c.status), c.path);
}
println!();
}
if !s.untracked.is_empty() {
println!(
"Untracked files:\n (use \"git add <file>...\" to include in what will be committed)"
);
for path in &s.untracked {
println!("\t{path}");
}
println!();
}
if s.staged.is_empty() {
if !s.unstaged.is_empty() {
println!("no changes added to commit (use \"git add\" and/or \"git commit -a\")");
} else if !s.untracked.is_empty() {
println!(
"nothing added to commit but untracked files present (use \"git add\" to track)"
);
} else if s.head.unborn {
println!("nothing to commit (create/copy files and use \"git add\" to track)");
} else {
println!("nothing to commit, working tree clean");
}
}
}
fn label(kind: &Kind) -> &'static str {
match kind {
Kind::Added => "new file:",
Kind::Modified => "modified:",
Kind::Deleted => "deleted:",
Kind::Renamed => "renamed:",
}
}
pub(crate) fn stat(diffs: &[FileDiff], summary: bool) {
summarize(diffs, true, summary);
}
pub(crate) fn commit_stat(diffs: &[FileDiff]) {
summarize(diffs, false, true);
}
fn summarize(diffs: &[FileDiff], names: bool, summary: bool) {
let width = diffs.iter().map(|d| d.path.len()).max().unwrap_or(0);
let mut insertions = 0;
let mut deletions = 0;
for diff in diffs {
let added = diff
.patch
.lines()
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
.count();
let removed = diff
.patch
.lines()
.filter(|l| l.starts_with('-') && !l.starts_with("---"))
.count();
insertions += added;
deletions += removed;
if names {
println!(
" {:width$} | {} {}{}",
diff.path,
added + removed,
"+".repeat(added),
"-".repeat(removed)
);
}
}
if diffs.is_empty() {
return;
}
print!(
" {} file{} changed",
diffs.len(),
if diffs.len() == 1 { "" } else { "s" }
);
if insertions > 0 {
print!(
", {insertions} insertion{}(+)",
if insertions == 1 { "" } else { "s" }
);
}
if deletions > 0 {
print!(
", {deletions} deletion{}(-)",
if deletions == 1 { "" } else { "s" }
);
}
println!();
if summary {
for d in diffs {
match d.status {
Kind::Added => println!(" create mode 100644 {}", d.path),
Kind::Deleted => println!(" delete mode 100644 {}", d.path),
_ => (),
}
}
}
}
pub(crate) fn conflicts(paths: &[String], merge: bool) {
for path in paths {
println!("Auto-merging {path}");
println!("CONFLICT (content): Merge conflict in {path}");
}
if merge && !paths.is_empty() {
println!("Automatic merge failed; fix conflicts and then commit the result.");
}
}
+672
View File
@@ -0,0 +1,672 @@
//! Single-ref receive-pack client over standard wasi:http.
use crate::Result;
use crate::{
http::{WasiHttp, clean_url},
remotes, repo,
repo::error,
};
use gix::bstr::ByteSlice;
use gix_transport::client::blocking_io::http::{Http, PostBodyDataKind};
use std::{
collections::{BTreeMap, BTreeSet},
io::{Read, Write},
};
/// Push one branch or explicit single branch refspec over HTTPS or the SSH exec transport.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PushParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Configured remote, default origin (pushurl takes precedence).
pub remote: Option<String>,
/// Short local branch, default current branch.
pub branch: Option<String>,
/// Single branch refspec: source:destination (including HEAD:destination), source, or :destination to delete. No wildcards.
pub refspec: Option<String>,
/// Allow non-fast-forward replacement. A leading + in refspec also enables this.
pub force: Option<bool>,
/// Set source branch upstream to the remote destination after success.
pub set_upstream: Option<bool>,
/// Delete the destination instead of sending objects (default false).
pub delete: Option<bool>,
}
/// Confirmed remote update; local tracking state is updated only after report-status success.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PushResult {
/// Remote used.
pub remote: String,
/// Full destination ref.
pub reference: String,
/// Advertised old object ID, or null for a new ref.
pub old: Option<String>,
/// New object ID, or null for deletion.
pub new: Option<String>,
/// Whether the server already had the requested value.
pub up_to_date: bool,
/// Number of objects sent (no delta compression).
pub objects: usize,
}
fn branch_ref(name: &str) -> Result<String> {
let full = if name.starts_with("refs/") {
name.to_owned()
} else {
format!("refs/heads/{name}")
};
if name.is_empty() || name.starts_with('-') || !full.starts_with("refs/heads/") {
return Err(error(
"push refspec",
"expected a branch name or refs/heads/<name>; tags and wildcard/multi-ref pushes are not supported",
));
}
let _: gix::refs::FullName = full
.as_str()
.try_into()
.map_err(|e| error("push refspec", e))?;
Ok(full)
}
pub(crate) struct Plan {
pub source: Option<String>,
pub destination: String,
pub new: Option<gix::ObjectId>,
pub force: bool,
}
pub(crate) fn plan(repo: &gix::Repository, p: &PushParams) -> Result<Plan> {
let mut force = p.force.unwrap_or(false);
let (source, destination, deleting) = if let Some(spec) = &p.refspec {
if p.branch.is_some() {
return Err(error("push", "branch and refspec are mutually exclusive"));
}
let spec = if let Some(s) = spec.strip_prefix('+') {
force = true;
s
} else {
spec.as_str()
};
let (src, dst) = spec.split_once(':').unwrap_or((spec, spec));
if dst.is_empty() || dst.contains(':') || spec.contains('*') {
return Err(error(
"push refspec",
"expected one source:destination or :destination",
));
}
let deleting = p.delete.unwrap_or(false) || src.is_empty();
let source = if deleting {
None
} else if src == "HEAD" {
Some(
crate::head_info(repo)?
.branch
.unwrap_or_else(|| "HEAD".into()),
)
} else {
Some(branch_ref(src)?)
};
(source, branch_ref(dst)?, deleting)
} else {
let source = match &p.branch {
Some(branch) => branch_ref(branch)?,
None => crate::head_info(repo)?
.branch
.ok_or_else(|| error("push", "detached HEAD: specify branch or refspec"))?,
};
let deleting = p.delete.unwrap_or(false);
(
if deleting { None } else { Some(source.clone()) },
source,
deleting,
)
};
if deleting && p.set_upstream.unwrap_or(false) {
return Err(error("push", "set_upstream cannot be used with deletion"));
}
if source.as_deref() == Some("HEAD") && p.set_upstream.unwrap_or(false) {
return Err(error(
"push upstream",
"detached HEAD has no branch to set upstream for",
));
}
let new = source
.as_deref()
.map(|src| -> Result<gix::ObjectId> {
let id = if src == "HEAD" {
repo.head_id()
.map_err(|e| error("push source", e))?
.detach()
} else {
let reference = repo
.find_reference(src)
.map_err(|e| error("push source", e))?;
reference
.try_id()
.ok_or_else(|| error("push source", "source must directly name a commit"))?
.detach()
};
repo.find_commit(id)
.map_err(|e| error("push source commit", e))?;
Ok(id)
})
.transpose()?;
Ok(Plan {
source,
destination,
new,
force,
})
}
// pkt-line readers reject truncation and reserved lengths rather than treating missing status as success.
pub(crate) fn pkt(out: &mut impl Write, data: &[u8]) -> Result<()> {
if data.len() > 65516 {
return Err(error("push packet", "packet too large"));
}
write!(out, "{:04x}", data.len() + 4)
.and_then(|_| out.write_all(data))
.map_err(|e| error("push packet", e))
}
fn packet(input: &mut impl Read) -> Result<Option<Vec<u8>>> {
let mut header = [0; 4];
input
.read_exact(&mut header)
.map_err(|e| error("push packet header", e))?;
let len = std::str::from_utf8(&header)
.ok()
.and_then(|s| u16::from_str_radix(s, 16).ok())
.ok_or_else(|| error("push packet", "invalid length"))? as usize;
if len == 0 {
return Ok(None);
}
if !(4..=65520).contains(&len) {
return Err(error("push packet", "invalid packet length"));
}
let mut bytes = vec![0; len - 4];
input
.read_exact(&mut bytes)
.map_err(|e| error("push packet body", e))?;
Ok(Some(bytes))
}
pub(crate) struct Advertisement {
pub refs: BTreeMap<String, gix::ObjectId>,
pub caps: BTreeSet<String>,
}
fn advertise(input: &mut impl Read) -> Result<Advertisement> {
if packet(input)?.as_deref() != Some(b"# service=git-receive-pack\n")
|| packet(input)?.is_some()
{
return Err(error(
"push advertisement",
"expected smart HTTP git-receive-pack service",
));
}
advertise_refs(input)
}
/// SSH hook: git-receive-pack exec channels begin directly with these ref packets,
/// without the smart-HTTP service prelude. commands(), pack(), and report() are also transport-neutral.
pub(crate) fn advertise_refs(input: &mut impl Read) -> Result<Advertisement> {
let mut refs = BTreeMap::new();
let mut caps = BTreeSet::new();
let mut first = true;
let mut count = 0;
while let Some(line) = packet(input)? {
count += 1;
if count > 100_000 {
return Err(error("push advertisement", "too many refs"));
}
let text = std::str::from_utf8(&line)
.map_err(|e| error("push advertisement", e))?
.trim_end_matches('\n');
if let Some(message) = text.strip_prefix("ERR ") {
return Err(error("push remote", message));
}
if text.starts_with("shallow ") {
return Err(error(
"push advertisement",
"shallow remotes are not supported",
));
}
let (reference, capabilities) = text
.split_once('\0')
.map(|(a, b)| (a, Some(b)))
.unwrap_or((text, None));
if let Some(capabilities) = capabilities {
if !first {
return Err(error("push advertisement", "capabilities after first ref"));
}
caps.extend(capabilities.split_ascii_whitespace().map(str::to_owned));
}
first = false;
let (oid, name) = reference
.split_once(' ')
.ok_or_else(|| error("push advertisement", "invalid ref line"))?;
let id =
gix::ObjectId::from_hex(oid.as_bytes()).map_err(|e| error("push advertisement", e))?;
if id.kind() != gix::hash::Kind::Sha1 {
return Err(error("push", "only SHA-1 repositories supported"));
}
if !id.is_null() {
refs.insert(name.to_owned(), id);
}
}
if !caps.contains("report-status") {
return Err(error("push", "remote does not support report-status"));
}
if caps.contains("object-format=sha256") {
return Err(error("push", "SHA-256 remote is unsupported"));
}
Ok(Advertisement { refs, caps })
}
pub(crate) fn commands(
old: Option<gix::ObjectId>,
plan: &Plan,
caps: &[&str],
out: &mut impl Write,
) -> Result<()> {
let zero = gix::hash::Kind::Sha1.null();
let command = format!(
"{} {} {}\0{}\n",
old.unwrap_or(zero),
plan.new.unwrap_or(zero),
plan.destination,
caps.join(" ")
);
pkt(out, command.as_bytes())?;
out.write_all(b"0000")
.map_err(|e| error("push commands", e))
}
/// Unknown remote tips are not assumed to be ancestors. Force is required until fetched.
pub(crate) fn is_ancestor(
repo: &gix::Repository,
old: gix::ObjectId,
new: gix::ObjectId,
) -> Result<bool> {
if old == new {
return Ok(true);
}
if repo
.try_find_object(old)
.map_err(|e| error("push ancestry", e))?
.is_none()
{
return Ok(false);
}
let walk = repo
.find_commit(new)
.map_err(|e| error("push ancestry", e))?
.ancestors()
.use_commit_graph(false)
.all()
.map_err(|e| error("push ancestry", e))?;
for item in walk {
if item.map_err(|e| error("push ancestry", e))?.id == old {
return Ok(true);
}
}
Ok(false)
}
/// Walk the object graph once, stopping BEFORE decoding seen objects or remote boundaries.
/// Returns the number of objects decoded, also used by the traversal-cost regression tests.
/// The boundary must be transitively closed (the reachable remote object set).
/// Gitlink commits belong to another repository and are never followed.
pub(crate) fn reachable(
repo: &gix::Repository,
tip: gix::ObjectId,
seen: &mut BTreeSet<gix::ObjectId>,
boundary: &BTreeSet<gix::ObjectId>,
) -> Result<usize> {
let mut pending = vec![tip];
let mut decoded = 0;
while let Some(id) = pending.pop() {
if boundary.contains(&id) || !seen.insert(id) {
continue;
}
let obj = repo
.find_object(id)
.map_err(|e| error("push reachable object", e))?;
decoded += 1;
match obj.kind {
gix::objs::Kind::Commit => {
let commit = obj.try_into_commit().map_err(|e| error("push commit", e))?;
pending.push(
commit
.tree_id()
.map_err(|e| error("push tree", e))?
.detach(),
);
pending.extend(commit.parent_ids().map(|id| id.detach()));
}
// Follow one tag at a time, including intermediate annotated tags in seen.
gix::objs::Kind::Tag => {
let tag = obj
.try_into_tag()
.map_err(|e| error("push remote tag", e))?;
pending.push(
tag.target_id()
.map_err(|e| error("push remote tag target", e))?
.detach(),
);
}
gix::objs::Kind::Tree => {
let tree = obj.try_into_tree().map_err(|e| error("push tree", e))?;
for entry in tree.iter() {
let entry = entry.map_err(|e| error("push tree entry", e))?;
if entry.mode().is_tree() {
pending.push(entry.object_id());
} else if !entry.mode().is_commit() && !boundary.contains(&entry.object_id()) {
seen.insert(entry.object_id());
}
}
}
gix::objs::Kind::Blob => {}
}
}
Ok(decoded)
}
pub(crate) fn objects_to_send(
repo: &gix::Repository,
new: gix::ObjectId,
remote: impl IntoIterator<Item = gix::ObjectId>,
) -> Result<BTreeSet<gix::ObjectId>> {
let mut theirs = BTreeSet::new();
let empty = BTreeSet::new();
for id in remote {
if !theirs.contains(&id)
&& repo
.try_find_object(id)
.map_err(|e| error("push remote object", e))?
.is_some()
{
reachable(repo, id, &mut theirs, &empty)?;
}
}
let mut ours = BTreeSet::new();
reachable(repo, new, &mut ours, &theirs)?;
Ok(ours)
}
/// Compress one object at a time and stream pack bytes to the HTTP request spool.
pub(crate) fn pack(
repo: &gix::Repository,
ids: &BTreeSet<gix::ObjectId>,
out: &mut impl Write,
) -> Result<()> {
use gix_pack::data::output::{Count, Entry, bytes::FromEntriesIter};
let count = u32::try_from(ids.len()).map_err(|e| error("push pack count", e))?;
let entries = ids.iter().map(|id| {
let object = repo.find_object(*id)?;
Entry::from_data(
&Count::from_data(*id, None),
&gix::objs::Data {
kind: object.kind,
data: &object.data,
object_hash: gix::hash::Kind::Sha1,
},
gix_zlib::Compression::default(),
)
.map(|entry| vec![entry])
});
for written in FromEntriesIter::new(
entries,
out,
count,
gix_pack::data::Version::V2,
gix::hash::Kind::Sha1,
) {
written.map_err(|e| error("push pack", format!("{e:#}")))?;
}
Ok(())
}
fn parse_status(input: &mut impl Read, reference: &str) -> Result<()> {
let unpack = packet(input)?.ok_or_else(|| error("push status", "missing unpack status"))?;
let unpack = String::from_utf8_lossy(&unpack);
if unpack.trim_end() != "unpack ok" {
return Err(error("push remote", unpack.trim_end()));
}
let mut confirmed = false;
while let Some(line) = packet(input)? {
let line = String::from_utf8_lossy(&line);
let line = line.trim_end();
if line == format!("ok {reference}") && !confirmed {
confirmed = true;
} else if let Some(message) = line.strip_prefix("ng ") {
return Err(error("push rejected", message));
} else {
return Err(error("push status", format!("unexpected status: {line}")));
}
}
if !confirmed {
return Err(error(
"push status",
"remote did not confirm the destination ref",
));
}
Ok(())
}
// Hook explanations often arrive on band 2, with only a generic reason in ng.
// Keep a bounded byte tail (lossy UTF-8 conversion happens only when rendering it).
const REMOTE_DETAIL_LIMIT: usize = 8192;
fn with_remote_detail(result: Result<()>, tail: &[u8]) -> Result<()> {
result.map_err(|mut e| {
let detail = String::from_utf8_lossy(tail);
if !detail.trim().is_empty() {
e.message.push_str(&format!("\nremote: {}", detail.trim()));
}
e
})
}
pub(crate) fn report(input: &mut impl Read, reference: &str, sideband: bool) -> Result<()> {
if !sideband {
return parse_status(input, reference);
}
let mut status = Vec::new();
let mut remote_detail = Vec::new();
while let Some(line) = packet(input)? {
match line.split_first() {
Some((1, bytes)) => {
if status.len() + bytes.len() > 1024 * 1024 {
return Err(error("push status", "report-status too large"));
}
status.extend_from_slice(bytes);
}
Some((2, bytes)) => {
remote_detail.extend_from_slice(bytes);
if remote_detail.len() > REMOTE_DETAIL_LIMIT {
remote_detail.drain(..remote_detail.len() - REMOTE_DETAIL_LIMIT);
}
}
Some((3, bytes)) => {
return with_remote_detail(
Err(error(
"push remote",
String::from_utf8_lossy(bytes).trim_end(),
)),
&remote_detail,
);
}
_ => return Err(error("push sideband", "invalid channel")),
}
}
with_remote_detail(
parse_status(&mut status.as_slice(), reference),
&remote_detail,
)
}
fn update_local(repo: &gix::Repository, remote: &str, plan: &Plan, upstream: bool) -> Result<()> {
let short = plan
.destination
.strip_prefix("refs/heads/")
.ok_or_else(|| error("push tracking", "not a branch"))?;
let tracking = format!("refs/remotes/{remote}/{short}");
use gix::refs::{
Target,
transaction::{Change, PreviousValue, RefEdit},
};
if let Some(new) = plan.new {
repo.edit_references_as(
[RefEdit {
name: tracking
.as_str()
.try_into()
.map_err(|e| error("push tracking", e))?,
deref: false,
change: Change::Update {
log: Default::default(),
expected: PreviousValue::Any,
new: Target::Object(new),
},
}],
None,
)
.map_err(|e| error("push tracking", e))?;
} else if let Some(reference) = repo
.try_find_reference(&tracking)
.map_err(|e| error("push tracking", e))?
{
reference.delete().map_err(|e| error("push tracking", e))?;
}
if upstream {
let branch = plan
.source
.as_deref()
.and_then(|s| s.strip_prefix("refs/heads/"))
.ok_or_else(|| error("push upstream", "missing source branch"))?;
let mut config = remotes::load(repo)?;
let ids: Vec<_> = config
.sections_and_ids_by_name("branch")
.into_iter()
.flatten()
.filter(|(s, _)| {
s.header()
.subsection_name()
.is_some_and(|s| s == branch.as_bytes())
})
.map(|(_, id)| id)
.collect();
for id in ids {
if let Some(mut section) = config.section_mut_by_id(id) {
while section.remove("remote").is_some() {}
while section.remove("merge").is_some() {}
}
}
let mut section = config
.section_mut_or_create_new("branch", branch)
.map_err(|e| error("push upstream", e))?;
section
.push("remote", Some(remote.into()))
.map_err(|e| error("push upstream", e))?;
section
.push("merge", Some(plan.destination.as_str().into()))
.map_err(|e| error("push upstream", e))?;
remotes::save(repo, &config)?;
}
Ok(())
}
/// Send one reference update through the smart-HTTP receive-pack service.
pub fn push(p: PushParams) -> Result<PushResult> {
let mut repo = repo::open(&p.repo)?;
repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable;
let remote = p.remote.as_deref().unwrap_or("origin");
remotes::validate_name(remote)?;
let plan = plan(&repo, &p)?;
if plan.new.is_some()
&& repo
.path()
.join("shallow")
.try_exists()
.map_err(|e| error("push shallow", e))?
{
return Err(error(
"push",
"shallow pushes are not supported; fetch full history first",
));
}
let configured = repo
.find_remote(remote)
.map_err(|_| error("push", "configured remote not found"))?;
let url = configured
.url(gix::remote::Direction::Push)
.ok_or_else(|| error("push", "remote has no push URL"))?
.to_bstring()
.to_str_lossy()
.into_owned();
let base = clean_url(&url)
.map_err(|e| error("push URL", e))?
.to_string()
.trim_end_matches('/')
.to_owned();
let mut http = WasiHttp::default();
let result = (|| {
let mut discovery = http
.get(
&format!("{base}/info/refs?service=git-receive-pack"),
&base,
["Accept: application/x-git-receive-pack-advertisement"],
)
.map_err(|e| error("push discovery", format!("{e:#}")))?;
let advertised = advertise(&mut discovery.body)?;
drop(discovery);
let old = advertised.refs.get(&plan.destination).copied();
if let (Some(old), Some(new)) = (old, plan.new) {
if old != new && !plan.force && !is_ancestor(&repo, old, new)? {
return Err(error(
"push",
"non-fast-forward (or remote tip unknown locally); fetch first or use force",
));
}
}
let up_to_date = old == plan.new;
let mut objects = 0;
if !up_to_date {
if plan.new.is_none() && !advertised.caps.contains("delete-refs") {
return Err(error("push", "remote does not allow deleting refs"));
}
let ids = match plan.new {
Some(new) => objects_to_send(&repo, new, advertised.refs.values().copied())?,
None => BTreeSet::new(),
};
objects = ids.len();
let sideband = advertised.caps.contains("side-band-64k");
let mut caps = vec!["report-status"];
if sideband {
caps.push("side-band-64k");
}
if advertised.caps.iter().any(|c| c.starts_with("agent=")) {
caps.push("agent=sausage-git/0.1");
}
let base = http.redirected_base_url().unwrap_or(base);
let mut post = http
.post(
&format!("{base}/git-receive-pack"),
&base,
[
"Content-Type: application/x-git-receive-pack-request",
"Accept: application/x-git-receive-pack-result",
],
PostBodyDataKind::Unbounded,
)
.map_err(|e| error("push request", format!("{e:#}")))?;
commands(old, &plan, &caps, &mut post.post_body)?;
if plan.new.is_some() {
pack(&repo, &ids, &mut post.post_body)?;
}
drop(post.post_body);
report(&mut post.body, &plan.destination, sideband)?;
}
update_local(&repo, remote, &plan, p.set_upstream.unwrap_or(false)).map_err(|e| {
error(
"push succeeded remotely, but local state update failed",
e.message,
)
})?;
Ok(PushResult {
remote: remote.into(),
reference: plan.destination,
old: old.map(|id| id.to_string()),
new: plan.new.map(|id| id.to_string()),
up_to_date,
objects,
})
})();
result
}
+581
View File
@@ -0,0 +1,581 @@
//! Linear rebase sequencer. Every entry point is called under the common .git lock.
//!
//! Own persistent format (NOT Git's sequencer format): rebase-merge/sausage.json,
//! version 1. Full hex OIDs; onto/orig_head/orig_tip/branch are immutable; todo is
//! oldest-first, done lists consumed original commits (including skipped/empty),
//! current is the conflicted original commit. tip is the unpublished replay tip.
//! work_tree records the last materialized tree; orig_index is base64 of the exact
//! original index. phase checkpoints precede worktree/ref writes. Atomic JSON rename
//! makes each pick restartable; refs stay original until Finish. New fields must
//! default; unknown versions refuse. Real git is neither used nor expected to read it.
use crate::Result;
use crate::{branches, checkout, merge, repo::error, staging};
use base64::{Engine, engine::general_purpose::STANDARD};
use gix::bstr::ByteSlice;
use std::{collections::BTreeSet, fs};
/// Replay a local branch's linear commits onto another revision.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct RebaseParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// New base; defaults to the selected branch's configured upstream.
pub onto: Option<String>,
/// Short or full local branch name; defaults to current branch (not detached HEAD).
pub branch: Option<String>,
}
/// Rebase progress. HEAD and the branch ref remain original until completion.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct RebaseResult {
/// HEAD after this call (original while pending).
pub head: crate::HeadInfo,
/// Unpublished replay tip while pending; final branch tip otherwise.
pub tip: String,
/// True when continue, skip or abort is needed.
pub pending: bool,
/// True when the selected branch already contains the requested base.
pub already_up_to_date: bool,
/// True when no replay was required and the branch advanced to onto.
pub fast_forward: bool,
/// Sorted unresolved paths in stages 1/2/3.
pub conflicts: Vec<String>,
/// Checkout limitations, including preserved abort obstructions.
pub warnings: Vec<String>,
/// Commit created from a staged conflict resolution during this call.
pub resolved: Option<String>,
}
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Eq)]
enum Phase {
Replay,
CheckoutConflict,
Conflict,
Reset,
Finish,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct State {
version: u32,
onto: String,
orig_head: String,
orig_branch: Option<String>,
branch: String,
orig_tip: String,
tip: String,
todo: Vec<String>,
done: Vec<String>,
current: Option<String>,
work_tree: String,
orig_index: Option<String>,
phase: Phase,
already: bool,
fast_forward: bool,
}
fn oid(s: &str) -> Result<gix::ObjectId> {
gix::ObjectId::from_hex(s.as_bytes()).map_err(|e| error("rebase state OID", e))
}
fn tree(repo: &gix::Repository, id: gix::ObjectId) -> Result<gix::ObjectId> {
Ok(repo
.find_commit(id)
.map_err(|e| error("rebase commit", e))?
.tree_id()
.map_err(|e| error("rebase tree", e))?
.detach())
}
fn state_path(repo: &gix::Repository) -> std::path::PathBuf {
repo.git_dir().join("rebase-merge/sausage.json")
}
fn save(repo: &gix::Repository, s: &State) -> Result<()> {
let bytes = serde_json::to_vec(s).map_err(|e| error("rebase state", e))?;
let tmp = repo.git_dir().join("rebase-merge/sausage.json.tmp");
fs::write(&tmp, bytes).map_err(|e| error("rebase state write", e))?;
fs::rename(tmp, state_path(repo)).map_err(|e| error("rebase state rename", e))
}
fn load(repo: &gix::Repository) -> Result<State> {
let bytes = fs::read(state_path(repo))
.map_err(|e| error("rebase: no readable Sausage rebase state", e))?;
let s: State = serde_json::from_slice(&bytes).map_err(|e| error("rebase state", e))?;
if s.version != 1 {
return Err(error("rebase state", "unsupported version"));
}
Ok(s)
}
fn clear(repo: &gix::Repository) -> Result<()> {
fs::remove_file(state_path(repo)).map_err(|e| error("rebase cleanup", e))?;
let tmp = repo.git_dir().join("rebase-merge/sausage.json.tmp");
if tmp.exists() {
fs::remove_file(tmp).map_err(|e| error("rebase cleanup", e))?;
}
// Never recursively remove someone else's sequencer files.
fs::remove_dir(repo.git_dir().join("rebase-merge")).map_err(|e| error("rebase cleanup", e))
}
fn conflicts(repo: &gix::Repository) -> Result<Vec<String>> {
let index = repo
.index_or_empty()
.map_err(|e| error("rebase index", e))?;
Ok(index
.entries()
.iter()
.filter(|e| e.stage_raw() != 0)
.map(|e| e.path(&index).to_str_lossy().into_owned())
.collect::<BTreeSet<_>>()
.into_iter()
.collect())
}
fn result(
repo: &gix::Repository,
s: &State,
pending: bool,
warnings: Vec<String>,
) -> Result<RebaseResult> {
Ok(RebaseResult {
head: crate::head_info(repo)?,
tip: s.tip.clone(),
pending,
already_up_to_date: s.already,
fast_forward: s.fast_forward,
conflicts: if pending { conflicts(repo)? } else { vec![] },
warnings,
resolved: None,
})
}
fn index_lock(repo: &gix::Repository) -> Result<()> {
if repo.git_dir().join("index.lock").exists() {
return Err(error("rebase", "index.lock already exists"));
}
Ok(())
}
fn verify_refs(repo: &gix::Repository, s: &State) -> Result<()> {
let head = crate::head_info(repo)?;
if head.id.as_deref() != Some(&s.orig_head)
|| head.branch != s.orig_branch
|| branches::resolve(repo, &s.branch)? != oid(&s.orig_tip)?
{
return Err(error(
"rebase",
"HEAD or branch changed since rebase began; refusing to overwrite refs",
));
}
Ok(())
}
fn upstream(repo: &gix::Repository, branch: &str) -> Result<String> {
// gix's remote-tracking lookup doesn't map local (remote=".") upstreams.
let short = branch
.strip_prefix("refs/heads/")
.ok_or_else(|| error("rebase upstream", "expected local branch"))?;
let config = repo.config_snapshot();
if config
.string(format!("branch.{short}.remote").as_str())
.is_some_and(|s| s.as_bstr() == b".".as_bstr())
{
return config
.string(format!("branch.{short}.merge").as_str())
.map(|s| s.to_str_lossy().into_owned())
.ok_or_else(|| error("rebase", "no upstream merge branch configured; supply onto"));
}
repo.find_reference(branch)
.map_err(|e| error("rebase branch", e))?
.remote_tracking_ref_name(gix::remote::Direction::Fetch)
.transpose()
.map_err(|e| error("rebase upstream", e))?
.map(|n| n.as_bstr().to_str_lossy().into_owned())
.ok_or_else(|| {
error(
"rebase",
"no upstream configured; supply onto or configure branch upstream",
)
})
}
pub fn start(repo: &gix::Repository, p: RebaseParams) -> Result<RebaseResult> {
let local = merge::merge_repository(repo)?;
let repo = &local;
merge::ensure_idle(repo)?;
merge::clean(repo)?;
let head = crate::head_info(repo)?;
let orig_head = head
.id
.ok_or_else(|| error("rebase", "unborn HEAD is unsupported"))?;
let branch = p
.branch
.map(|b| {
if b.starts_with("refs/heads/") {
b
} else {
format!("refs/heads/{b}")
}
})
.or_else(|| head.branch.clone())
.ok_or_else(|| error("rebase", "detached HEAD; supply a local branch"))?;
let _: gix::refs::FullName = branch
.as_str()
.try_into()
.map_err(|e| error("rebase branch", e))?;
if !branch.starts_with("refs/heads/") {
return Err(error("rebase", "expected local branch"));
}
let orig = branches::resolve(repo, &branch)?;
let onto = branches::resolve(
repo,
&match p.onto {
Some(r) => r,
None => upstream(repo, &branch)?,
},
)?;
let base = repo
.merge_base(orig, onto)
.map_err(|e| error("rebase merge-base", e))?
.ok_or_else(|| error("rebase", "unrelated histories are unsupported"))?
.detach();
let mut cursor = orig;
let mut todo = Vec::new();
while cursor != base {
let c = repo
.find_commit(cursor)
.map_err(|e| error("rebase range", e))?;
let parents: Vec<_> = c.parent_ids().map(|id| id.detach()).collect();
if parents.len() != 1 {
return Err(error(
"rebase",
"linear history only: merge commit in replay range (no rebase-merges)",
));
}
todo.push(cursor.to_string());
cursor = parents[0];
}
todo.reverse();
let already = base == onto;
if already && head.branch.as_deref() == Some(&branch) {
return Ok(RebaseResult {
head: crate::head_info(repo)?,
tip: orig.to_string(),
pending: false,
already_up_to_date: true,
fast_forward: false,
conflicts: vec![],
warnings: vec![],
resolved: None,
});
}
if already {
todo.clear();
}
if !todo.is_empty() {
staging::signature(None, None)?;
}
// Preflight reflog configuration before materializing anything.
repo.committer()
.transpose()
.map_err(|e| error("rebase identity", e))?;
let orig_index = match fs::read(repo.git_dir().join("index")) {
Ok(b) => Some(STANDARD.encode(b)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(error("rebase snapshot index", e)),
};
let s = State {
version: 1,
onto: onto.to_string(),
orig_head: orig_head.clone(),
orig_branch: head.branch,
branch,
orig_tip: orig.to_string(),
tip: if already { orig } else { onto }.to_string(),
todo,
done: vec![],
current: None,
work_tree: tree(repo, oid(&orig_head)?)?.to_string(),
orig_index,
phase: Phase::Replay,
already,
fast_forward: !already && base == orig,
};
fs::create_dir(repo.git_dir().join("rebase-merge"))
.map_err(|e| error("rebase state directory", e))?;
save(repo, &s)?;
run(repo, s, vec![])
}
fn pick(
repo: &gix::Repository,
original: &str,
tip: &str,
new_tree: gix::ObjectId,
) -> Result<String> {
if new_tree == tree(repo, oid(tip)?)? {
return Ok(tip.to_owned());
}
let original = repo
.find_commit(oid(original)?)
.map_err(|e| error("rebase original", e))?;
let decoded = original.decode().map_err(|e| error("rebase original", e))?;
let author = decoded
.author()
.map_err(|e| error("rebase author", e))?
.to_owned()
.map_err(|e| error("rebase author", e))?;
let object = gix::objs::Commit {
tree: new_tree,
parents: vec![oid(tip)?].into_iter().collect(),
author,
committer: staging::signature(None, None)?,
encoding: decoded.encoding.map(ToOwned::to_owned),
message: decoded.message.to_owned(),
extra_headers: vec![],
};
Ok(repo
.write_object(&object)
.map_err(|e| error("rebase commit write", e))?
.to_string())
}
fn consume(s: &mut State) {
if !s.todo.is_empty() {
s.done.push(s.todo.remove(0));
}
s.current = None;
}
fn run(repo: &gix::Repository, mut s: State, mut warnings: Vec<String>) -> Result<RebaseResult> {
verify_refs(repo, &s)?;
while let Some(current) = s.todo.first().cloned() {
let commit = repo
.find_commit(oid(&current)?)
.map_err(|e| error("rebase pick", e))?;
let parent = commit
.parent_ids()
.next()
.ok_or_else(|| error("rebase pick", "root commit in range"))?
.detach();
let labels = gix::merge::blob::builtin_driver::text::Labels {
ancestor: None,
current: Some("ours".into()),
other: Some("theirs".into()),
};
let mut outcome = repo
.merge_trees(
tree(repo, parent)?,
tree(repo, oid(&s.tip)?)?,
tree(repo, oid(&current)?)?,
labels,
repo.tree_merge_options()
.map_err(|e| error("rebase options", e))?,
)
.map_err(|e| error("rebase merge trees", e))?;
let unresolved = outcome.has_unresolved_conflicts(Default::default());
let merged = outcome
.tree
.write()
.map_err(|e| error("rebase tree write", e))?
.detach();
let mut index = gix::index::State::from_tree(&merged, &repo.objects, Default::default())
.map_err(|e| error("rebase index", e))?;
outcome.index_changed_after_applying_conflicts(
&mut index,
Default::default(),
gix::merge::tree::apply_index_entries::RemovalMode::Prune,
);
index.sort_entries();
let has_conflicts = index.entries().iter().any(|e| e.stage_raw() != 0);
if unresolved && !has_conflicts {
return Err(error(
"rebase",
"gix conflict without index stages; refusing unsafe replay",
));
}
if has_conflicts {
s.current = Some(current);
s.phase = Phase::CheckoutConflict;
save(repo, &s)?;
warnings.extend(checkout::write_tree_to_worktree(
repo,
Some(oid(&s.work_tree)?),
merged,
false,
)?);
staging::publish(repo, index)?;
s.work_tree = merged.to_string();
s.phase = Phase::Conflict;
save(repo, &s)?;
return result(repo, &s, true, warnings);
}
s.tip = pick(repo, &current, &s.tip, merged)?;
consume(&mut s);
save(repo, &s)?;
}
s.phase = Phase::Finish;
save(repo, &s)?;
finish(repo, s, warnings)
}
fn finish(repo: &gix::Repository, s: State, mut warnings: Vec<String>) -> Result<RebaseResult> {
verify_refs(repo, &s)?;
let target = tree(repo, oid(&s.tip)?)?;
// A process may exit after checkout publishes its index but before the ref CAS.
// In that case the worktree is already final; do not compare it to the old tree.
if resolved_tree(repo)? != target {
warnings.extend(checkout::write_tree_to_worktree(
repo,
Some(oid(&s.work_tree)?),
target,
false,
)?);
}
use gix::refs::{
Target,
transaction::{Change, PreviousValue, RefEdit},
};
let old_head = match &s.orig_branch {
Some(b) => Target::Symbolic(b.as_str().try_into().map_err(|e| error("rebase HEAD", e))?),
None => Target::Object(oid(&s.orig_head)?),
};
warnings.extend(branches::edit_refs(
repo,
vec![
RefEdit {
name: s
.branch
.as_str()
.try_into()
.map_err(|e| error("rebase branch", e))?,
deref: false,
change: Change::Update {
expected: PreviousValue::MustExistAndMatch(Target::Object(oid(&s.orig_tip)?)),
new: Target::Object(oid(&s.tip)?),
log: Default::default(),
},
},
RefEdit {
name: "HEAD".try_into().map_err(|e| error("rebase HEAD", e))?,
deref: false,
change: Change::Update {
expected: PreviousValue::MustExistAndMatch(old_head),
new: Target::Symbolic(
s.branch
.as_str()
.try_into()
.map_err(|e| error("rebase branch", e))?,
),
log: Default::default(),
},
},
],
)?);
clear(repo)?;
result(repo, &s, false, warnings)
}
/// Check only unstaged changes: HEAD intentionally still names the original branch tip.
fn resolved_tree(repo: &gix::Repository) -> Result<gix::ObjectId> {
let index = repo
.index_or_empty()
.map_err(|e| error("rebase index", e))?;
let tree = staging::tree_from_index(repo, &index)?;
let status = crate::changes::status(repo)?;
if !status.unstaged.is_empty() {
return Err(error(
"rebase continue",
"unstaged changes; stage the resolved paths first",
));
}
Ok(tree)
}
pub fn resume(repo: &gix::Repository, skip: bool) -> Result<RebaseResult> {
index_lock(repo)?;
let local = merge::merge_repository(repo)?;
let repo = &local;
let mut s = load(repo)?;
// Ref transaction completed but cleanup was interrupted: do not replay twice.
let head = crate::head_info(repo)?;
if s.phase == Phase::Finish
&& head.branch.as_deref() == Some(&s.branch)
&& head.id.as_deref() == Some(&s.tip)
&& branches::resolve(repo, &s.branch)? == oid(&s.tip)?
{
clear(repo)?;
return result(repo, &s, false, vec![]);
}
verify_refs(repo, &s)?;
if s.phase == Phase::CheckoutConflict {
return Err(error(
"rebase",
"interrupted conflict publication; use rebase_abort (resolution index may be incomplete)",
));
}
if skip && s.phase != Phase::Conflict {
return Err(error("rebase skip", "no conflicted pick to skip"));
}
let mut warnings = vec![];
let mut resolved = None;
if s.phase == Phase::Conflict {
if skip {
consume(&mut s);
s.phase = Phase::Reset;
save(repo, &s)?;
} else {
let merged = resolved_tree(repo)?;
let current = s
.current
.as_deref()
.ok_or_else(|| error("rebase state", "missing current pick"))?;
s.tip = pick(repo, current, &s.tip, merged)?;
resolved = Some(s.tip.clone());
s.work_tree = merged.to_string();
consume(&mut s);
s.phase = Phase::Replay;
save(repo, &s)?;
}
}
if s.phase == Phase::Reset {
let target = tree(repo, oid(&s.tip)?)?;
warnings.extend(checkout::write_tree_to_worktree(
repo,
Some(oid(&s.work_tree)?),
target,
true,
)?);
s.work_tree = target.to_string();
s.phase = Phase::Replay;
save(repo, &s)?;
}
if s.phase == Phase::Finish {
return finish(repo, s, warnings);
}
if resolved_tree(repo)? != oid(&s.work_tree)? {
return Err(error(
"rebase",
"index changed during interrupted replay; abort or restore the index before continuing",
));
}
let mut result = run(repo, s, warnings)?;
result.resolved = resolved;
Ok(result)
}
pub fn abort(repo: &gix::Repository) -> Result<RebaseResult> {
index_lock(repo)?;
let s = load(repo)?;
verify_refs(repo, &s)?;
let warnings = checkout::abort_to_tree(repo, tree(repo, oid(&s.orig_head)?)?)?;
if let Some(bytes) = &s.orig_index {
use std::io::Write;
let bytes = STANDARD
.decode(bytes)
.map_err(|e| error("rebase original index", e))?;
let lock = repo.git_dir().join("index.lock");
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock)
.map_err(|e| error("rebase index lock", e))?;
let write = file.write_all(&bytes).and_then(|_| file.flush());
drop(file);
if let Err(e) = write {
let _ = fs::remove_file(&lock);
return Err(error("rebase index restore", e));
}
fs::rename(&lock, repo.git_dir().join("index"))
.map_err(|e| error("rebase index restore", e))?;
} else {
match fs::remove_file(repo.git_dir().join("index")) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(error("rebase index restore", e)),
}
}
clear(repo)?;
let mut r = result(repo, &s, false, warnings)?;
r.tip = s.orig_tip;
Ok(r)
}
+187
View File
@@ -0,0 +1,187 @@
//! Non-destructive local Git config edits. No plugin-owned persistent format.
use crate::Result;
use crate::{http::clean_url, repo::error};
use gix::bstr::ByteSlice;
/// Remote identity.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct RemoteParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Remote name.
pub name: String,
}
/// Add or change a remote URL.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct RemoteUrlParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Remote name.
pub name: String,
/// HTTPS without credentials, ssh:// URL, or [user@]host:path.
pub url: String,
}
/// Remote settings stored in local .git/config.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct RemoteInfo {
/// Remote name.
pub name: String,
/// Credential-free URL, if configured.
pub url: Option<String>,
/// Fetch refspecs in config order.
pub fetch: Vec<String>,
}
pub fn validate_name(name: &str) -> Result<()> {
if name.is_empty()
|| name.starts_with('-')
|| name.contains('/')
|| name.contains("..")
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
{
return Err(error(
"remote",
"expected a remote name using letters, digits, ._-",
));
}
let _: gix::refs::FullName = format!("refs/remotes/{name}/branch")
.as_str()
.try_into()
.map_err(|e| error("remote name", e))?;
Ok(())
}
pub fn load(repo: &gix::Repository) -> Result<gix::config::File> {
let text = std::fs::read_to_string(repo.common_dir().join("config"))
.map_err(|e| error("remote config", e))?;
gix::config::File::try_from(text.as_str())
.map_err(|_| error("remote config", "could not parse .git/config"))
}
pub fn save(repo: &gix::Repository, config: &gix::config::File) -> Result<()> {
let mut bytes = Vec::new();
config
.write_to(&mut bytes)
.map_err(|e| error("remote config", e))?;
std::fs::write(repo.common_dir().join("config"), bytes).map_err(|e| error("config write", e))
}
pub fn list(repo: &gix::Repository) -> Result<Vec<RemoteInfo>> {
let config = load(repo)?;
let mut names = std::collections::BTreeSet::new();
for section in config.sections_by_name("remote").into_iter().flatten() {
if let Some(name) = section.header().subsection_name() {
names.insert(name.to_str_lossy().into_owned());
}
}
names
.into_iter()
.map(|name| {
let url = config
.string_by("remote", name.as_str(), "url")
.map(|s| s.to_str_lossy().into_owned());
// Do not return legacy embedded credentials from repositories imported outside this plugin.
let url = url
.map(|s| {
clean_url(&s).map(|u| u.to_string()).map_err(|_| {
error(
"remote list",
"remote URL must be HTTPS without credentials or SSH without password",
)
})
})
.transpose()?;
let fetch = config
.strings_by("remote", name.as_str(), "fetch")
.unwrap_or_default()
.into_iter()
.map(|s| s.to_str_lossy().into_owned())
.collect();
Ok(RemoteInfo { name, url, fetch })
})
.collect()
}
pub fn set(repo: &gix::Repository, name: &str, url: &str, add: bool) -> Result<RemoteInfo> {
validate_name(name)?;
let url = clean_url(url)
.map_err(|e| error("remote URL", e))?
.to_string();
let mut config = load(repo)?;
let exists = config.section("remote", name).is_ok();
if add == exists {
return Err(error(
"remote",
if add {
"remote already exists"
} else {
"remote does not exist"
},
));
}
// Replace all URL occurrences (including duplicate sections), preserving other keys/comments.
if !add {
let ids: Vec<_> = config
.sections_and_ids_by_name("remote")
.into_iter()
.flatten()
.filter(|(s, _)| {
s.header()
.subsection_name()
.is_some_and(|s| s == name.as_bytes())
})
.map(|(_, id)| id)
.collect();
for id in ids {
if let Some(mut section) = config.section_mut_by_id(id) {
while section.remove("url").is_some() {}
}
}
}
let mut section = config
.section_mut_or_create_new("remote", name)
.map_err(|e| error("remote config", e))?;
section
.push("url", Some(url.as_str().into()))
.map_err(|e| error("remote config", e))?;
if add {
section
.push(
"fetch",
Some(
format!("+refs/heads/*:refs/remotes/{name}/*")
.as_str()
.into(),
),
)
.map_err(|e| error("remote config", e))?;
}
save(repo, &config)?;
list(repo)?
.into_iter()
.find(|r| r.name == name)
.ok_or_else(|| error("remote", "remote missing after config edit"))
}
pub fn remove(repo: &gix::Repository, name: &str) -> Result<()> {
validate_name(name)?;
let mut config = load(repo)?;
if config.remove_section("remote", name).is_none() {
return Err(error("remote", "remote does not exist"));
}
while config.remove_section("remote", name).is_some() {}
save(repo, &config)
}
/// Configure the newly cloned branch's upstream with the same non-destructive editor.
pub fn upstream(repo: &gix::Repository, branch: &str) -> Result<()> {
let mut config = load(repo)?;
let mut section = config
.section_mut_or_create_new("branch", branch)
.map_err(|e| error("branch config", e))?;
section
.push("remote", Some("origin".into()))
.map_err(|e| error("branch config", e))?;
section
.push(
"merge",
Some(format!("refs/heads/{branch}").as_str().into()),
)
.map_err(|e| error("branch config", e))?;
save(repo, &config)
}
+112
View File
@@ -0,0 +1,112 @@
//! Repository layout and shared operation helpers. Persistent state is ordinary Git files only.
use crate::{Error, Result};
use std::path::PathBuf;
/// Add operation context to Git and filesystem errors.
pub fn error(operation: &str, error: impl std::fmt::Display) -> Error {
Error::new(-32000, format!("{operation}: {error}"))
}
/// Resolve a legacy name or a literal absolute path under /plugins/ or /repos/.
pub fn repo_path(name: &str) -> Result<PathBuf> {
Ok(PathBuf::from(name))
}
/// Resolve metadata without opening another repository or acquiring another lock.
/// Missing .git is retained for the existing non-bare init/clone lock key.
fn git_dir(path: &std::path::Path) -> Result<PathBuf> {
let dot_git = path.join(".git");
if dot_git.is_file() {
let text = std::fs::read_to_string(&dot_git).map_err(|e| error("gitfile", e))?;
let target = text
.trim_end_matches(['\r', '\n'])
.strip_prefix("gitdir: ")
.filter(|p| !p.is_empty() && !p.contains(['\r', '\n', '\0']))
.ok_or_else(|| error("gitfile", "expected gitdir: <path>"))?;
return std::fs::canonicalize(path.join(target)).map_err(|e| error("gitfile target", e));
}
if !dot_git.exists() && path.join("HEAD").is_file() && path.join("objects").is_dir() {
return Ok(path.to_owned());
}
Ok(dot_git)
}
/// Common-dir advisory lock key, shared by the main and all linked worktrees.
/// A plain .git directory retains exactly the legacy lock path.
pub fn lock_path(name: &str) -> Result<PathBuf> {
let git_dir = git_dir(&repo_path(name)?)?;
let common = match std::fs::read_to_string(git_dir.join("commondir")) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(git_dir),
Err(e) => return Err(error("commondir", e)),
};
let common = common.trim_end_matches(['\r', '\n']);
if common.is_empty() || common.contains(['\r', '\n', '\0']) {
return Err(error("commondir", "expected one nonempty path"));
}
std::fs::canonicalize(git_dir.join(common)).map_err(|e| error("commondir target", e))
}
/// Open only the addressed worktree or bare directory, never discover an ancestor.
pub fn open(name: &str) -> Result<gix::Repository> {
let path = repo_path(name)?;
let dot_git = path.join(".git");
let mut repo = if dot_git.is_file() {
// gix follows gitfiles and commondir, including the private HEAD/index.
git_dir(&path)?; // Give malformed gitfiles a useful error before opening.
let repo = gix::open(&path).map_err(|e| error("open", e))?;
// gix 0.90 reports is_bare()==true from the common config even though
// it resolves the workdir and private index correctly. Check workdir,
// not that cached flag; status and our tree writer work without config edits.
worktree(&repo)?;
repo
} else if dot_git.is_dir() {
let repo = gix::open(&dot_git).map_err(|e| error("open", e))?;
if repo.workdir().is_none() || repo.is_bare() {
return Err(error(
"open",
"expected worktree metadata in .git; address bare repositories by their bare directory",
));
}
repo
} else {
let repo = gix::open_opts(&path, gix::open::Options::default().open_path_as_is(true))
.map_err(|e| error("open bare", e))?;
if !repo.is_bare() {
return Err(error(
"open",
"expected a worktree root or bare repository directory",
));
}
repo
};
// gix-ref otherwise uses unsupported mmap for packed-refs above 32 KiB.
#[cfg(target_os = "wasi")]
repo.refs.set_packed_buffer_mmap_threshold(u64::MAX);
Ok(repo)
}
/// Require a worktree before any status/index/worktree-style operation.
pub fn worktree(repo: &gix::Repository) -> Result<&std::path::Path> {
repo.workdir().ok_or_else(|| {
error(
"worktree",
"no worktree: operation is not available on a bare repository",
)
})
}
/// Validate a literal repository-relative path (not a pathspec).
pub fn validate_path(path: &str) -> Result<()> {
if path.is_empty()
|| path
.split('/')
.any(|c| c.is_empty() || c == "." || c == "..")
{
return Err(error(
"path",
"expected a nonempty repository-relative literal path",
));
}
Ok(())
}
+771
View File
@@ -0,0 +1,771 @@
//! Staging uses ordinary Git index entries; callers hold the repository .git lock.
use crate::Result;
use crate::repo::{self, error};
use gix::{
bstr::ByteSlice,
index::{
State,
entry::{Flags, Mode, Stat},
},
};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::Path,
};
/// Stage literal paths or the whole worktree.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct AddParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Literal files/directories; no globbing. May be empty with all=true.
pub paths: Vec<String>,
/// Stage tracked changes/deletions and nonignored untracked files throughout the repo.
pub all: Option<bool>,
}
/// Reset selected index paths to HEAD, leaving worktree bytes untouched.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct PathsParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Nonempty list of literal file/directory paths; no globbing.
pub paths: Vec<String>,
}
/// Remove tracked paths from the index and optionally worktree.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct RmParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Nonempty list of literal file/directory paths.
pub paths: Vec<String>,
/// Leave files on disk (default false). Otherwise refuse staged or local modifications.
pub keep_file: Option<bool>,
}
/// Paths whose index entries were selected by an operation.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct StagedPaths {
/// Selected paths, sorted, including staged deletions.
pub paths: Vec<String>,
}
/// Create a commit from the stage-zero index.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct CommitParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Nonblank commit message, stored verbatim.
pub message: String,
/// Author display name, falling back to /config/git.yaml user.name.
pub author_name: Option<String>,
/// Author email, falling back to /config/git.yaml user.email.
pub author_email: Option<String>,
/// Stage modifications/deletions of tracked files first, not untracked files (default false).
pub all: Option<bool>,
/// Replace HEAD, retaining its parents and, unless overridden, original author (default false).
pub amend: Option<bool>,
/// Permit a tree equal to the first parent's tree, or an empty root tree (default false).
pub allow_empty: Option<bool>,
}
/// Published commit and resulting HEAD.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct CommitResult {
/// Full commit object ID.
pub id: String,
/// HEAD after the checked reference update.
pub head: crate::HeadInfo,
/// Detached-HEAD warning, if applicable.
pub warnings: Vec<String>,
}
fn validate(path: &str) -> Result<()> {
repo::validate_path(path)?;
if path.contains('\\') || path.split('/').any(|s| s.eq_ignore_ascii_case(".git")) {
return Err(error(
"path",
"paths under .git and backslashes are not allowed",
));
}
Ok(())
}
fn validate_paths(paths: &[String]) -> Result<()> {
if paths.is_empty() {
return Err(error("paths", "supply at least one literal path"));
}
for p in paths {
validate(p)?;
}
Ok(())
}
fn matches(path: &str, selected: &[String]) -> bool {
selected
.iter()
.any(|p| path == p || path.strip_prefix(p).is_some_and(|s| s.starts_with('/')))
}
fn meta(path: &Path) -> Result<Option<fs::Metadata>> {
match fs::symlink_metadata(path) {
Ok(m) => Ok(Some(m)),
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
Ok(None)
}
Err(e) => Err(error("stage stat", e)),
}
}
// Do not traverse symlink parents even when an explicit path points outside via a link.
fn safe(root: &Path, path: &str) -> Result<()> {
validate(path)?;
for parent in Path::new(path)
.ancestors()
.skip(1)
.filter(|p| !p.as_os_str().is_empty())
{
if meta(&root.join(parent))?.is_some_and(|m| m.file_type().is_symlink()) {
return Err(error("path", format!("symlink parent: {path}")));
}
}
Ok(())
}
fn load(repo: &gix::Repository) -> Result<State> {
repo::worktree(repo)?;
let index = repo.index_or_empty().map_err(|e| error("stage index", e))?;
let mut state = State::new(repo.object_hash());
for e in index.entries() {
let p = e
.path(&index)
.to_str()
.map_err(|e| error("index path UTF-8", e))?;
validate(p)?;
if e.mode == Mode::DIR || e.flags.contains(Flags::SKIP_WORKTREE) {
return Err(error("stage", "sparse indexes are unsupported"));
}
state.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, p.as_bytes().as_bstr());
}
state.sort_entries();
Ok(state)
}
struct PendingIndex {
lock: std::path::PathBuf,
dest: std::path::PathBuf,
published: bool,
}
impl Drop for PendingIndex {
fn drop(&mut self) {
if !self.published {
let _ = fs::remove_file(&self.lock);
}
}
}
impl PendingIndex {
fn commit(mut self) -> Result<()> {
fs::rename(&self.lock, &self.dest).map_err(|e| error("stage index rename", e))?;
self.published = true;
Ok(())
}
}
fn prepare(repo: &gix::Repository, mut state: State) -> Result<PendingIndex> {
use std::io::Write;
state.sort_entries();
state.remove_tree();
let dest = repo.git_dir().join("index");
let lock = repo.git_dir().join("index.lock");
// Do not create the cleanup guard until create_new succeeds: an existing lock is not ours.
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock)
.map_err(|e| error("stage index lock", e))?;
let pending = PendingIndex {
lock,
dest: dest.clone(),
published: false,
};
gix::index::File::from_state(state, dest)
.write_to(&mut file, Default::default())
.map_err(|e| error("stage index write", e))?;
file.flush().map_err(|e| error("stage index flush", e))?;
drop(file);
Ok(pending)
}
pub(crate) fn publish(repo: &gix::Repository, state: State) -> Result<()> {
prepare(repo, state)?.commit()
}
fn head_state(repo: &gix::Repository) -> Result<State> {
if repo.head().map_err(|e| error("stage HEAD", e))?.is_unborn() {
return Ok(State::new(repo.object_hash()));
}
let tree = repo
.head_commit()
.map_err(|e| error("stage HEAD", e))?
.tree_id()
.map_err(|e| error("stage tree", e))?
.detach();
State::from_tree(&tree, &repo.objects, Default::default()).map_err(|e| error("stage tree", e))
}
fn work_entry(
repo: &gix::Repository,
path: &str,
previous: Option<Mode>,
) -> Result<Option<(Stat, gix::ObjectId, Mode)>> {
let root = repo.workdir().ok_or_else(|| error("add", "no worktree"))?;
safe(root, path)?;
let file = root.join(path);
let Some(m) = meta(&file)? else {
return Ok(None);
};
if m.is_dir() {
return Ok(None);
}
let (bytes, mode) = if m.file_type().is_symlink() {
let target = fs::read_link(&file).map_err(|e| error("add symlink", e))?;
(
gix::path::into_bstr(target)
.map_err(|e| error("add symlink", e))?
.into_owned()
.to_vec(),
Mode::SYMLINK,
)
} else if m.is_file() {
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
if m.permissions().mode() & 0o111 != 0 {
Mode::FILE_EXECUTABLE
} else {
Mode::FILE
}
};
#[cfg(not(unix))]
let mode = if previous == Some(Mode::FILE_EXECUTABLE) {
Mode::FILE_EXECUTABLE
} else {
Mode::FILE
};
let _ = previous;
(fs::read(&file).map_err(|e| error("add read", e))?, mode)
} else {
return Err(error(
"add",
format!("not a regular file or symlink: {path}"),
));
};
let stat =
gix::index::fs::Metadata::from_path_no_follow(&file).map_err(|e| error("add stat", e))?;
let stat = Stat::from_fs(&stat).map_err(|e| error("add stat", e))?;
let id = repo
.write_blob(&bytes)
.map_err(|e| error("add blob", e))?
.detach();
Ok(Some((stat, id, mode)))
}
fn stage(repo: &gix::Repository, state: State, selected: BTreeSet<String>) -> Result<State> {
// Group conflict stages too; keep unrelated entries verbatim. Avoid scanning and
// compacting the entire index for each selected path (quadratic for add-all).
let mut entries = BTreeMap::<String, Vec<_>>::new();
for e in state.entries() {
let path = e
.path(&state)
.to_str()
.map_err(|e| error("index path UTF-8", e))?
.to_owned();
entries
.entry(path)
.or_default()
.push((e.stat, e.id, e.flags, e.mode));
}
for path in selected {
let previous = entries.get(&path).and_then(|es| es.first()).map(|e| e.3);
if previous == Some(Mode::COMMIT) {
return Err(error("add", "staging submodules is unsupported"));
}
let entry = work_entry(repo, &path, previous)?;
entries.remove(&path);
if let Some((stat, id, mode)) = entry {
// Resolve file/directory replacements using a prefix range and ancestor
// lookups, not two full-index passes. Never implicitly remove a gitlink.
let prefix = format!("{path}/");
let mut collisions: Vec<_> = entries
.range(prefix.clone()..)
.take_while(|(p, _)| p.starts_with(&prefix))
.map(|(p, _)| p.clone())
.collect();
collisions.extend(
Path::new(&path)
.ancestors()
.skip(1)
.filter_map(Path::to_str)
.filter(|p| entries.contains_key(*p))
.map(str::to_owned),
);
for p in collisions {
if entries
.get(&p)
.is_some_and(|es| es.iter().any(|e| e.3 == Mode::COMMIT))
{
return Err(error(
"add",
"file/directory replacement would remove a submodule",
));
}
entries.remove(&p);
}
entries.insert(path, vec![(stat, id, Flags::empty(), mode)]);
}
}
let mut state = State::new(repo.object_hash());
for (path, es) in entries {
for (stat, id, flags, mode) in es {
state.dangerously_push_entry(stat, id, flags, mode, path.as_bytes().as_bstr());
}
}
state.sort_entries();
Ok(state)
}
pub(crate) fn add(repo: &gix::Repository, p: AddParams) -> Result<StagedPaths> {
if !p.all.unwrap_or(false) {
validate_paths(&p.paths)?;
}
for path in &p.paths {
validate(path)?;
}
let root = repo.workdir().ok_or_else(|| error("add", "no worktree"))?;
let state = load(repo)?;
let tracked: BTreeSet<_> = state
.entries()
.iter()
.filter(|e| e.mode != Mode::COMMIT)
.map(|e| e.path(&state).to_str_lossy().into_owned())
.collect();
let gitlinks: BTreeSet<_> = state
.entries()
.iter()
.filter(|e| e.mode == Mode::COMMIT)
.map(|e| e.path(&state).to_str_lossy().into_owned())
.collect();
// Directory expansion must use the ignore-aware walker, just like add-all.
// A directly named file remains force-addable even when ignored.
let needs_walk = p.all.unwrap_or(false) || p.paths.iter().any(|p| root.join(p).is_dir());
let untracked: BTreeSet<_> = if needs_walk {
crate::changes::status(repo)?
.untracked
.into_iter()
.collect()
} else {
BTreeSet::new()
};
let mut selected = BTreeSet::new();
if p.all.unwrap_or(false) {
selected.extend(tracked.iter().cloned());
selected.extend(untracked.iter().filter(|p| !gitlinks.contains(*p)).cloned());
}
for path in &p.paths {
safe(root, path)?;
if gitlinks.contains(path) {
return Err(error("add", "staging submodules is unsupported"));
}
selected.extend(
tracked
.iter()
.filter(|s| matches(s, std::slice::from_ref(path)))
.cloned(),
);
match meta(&root.join(path))? {
Some(m) if m.is_dir() => {
if root.join(path).join(".git").exists() {
return Err(error("add", "nested repositories are unsupported"));
}
selected.extend(
untracked
.iter()
.filter(|s| {
matches(s, std::slice::from_ref(path)) && !gitlinks.contains(*s)
})
.cloned(),
);
}
Some(_) => {
selected.insert(path.clone());
}
None => {
if !tracked
.iter()
.chain(&gitlinks)
.any(|s| matches(s, std::slice::from_ref(path)))
{
return Err(error(
"add",
format!("path does not exist and is not tracked: {path}"),
));
}
}
}
}
let paths = selected.iter().cloned().collect();
publish(repo, stage(repo, state, selected)?)?;
Ok(StagedPaths { paths })
}
pub(crate) fn unstage(repo: &gix::Repository, p: PathsParams) -> Result<StagedPaths> {
validate_paths(&p.paths)?;
let mut state = load(repo)?;
let head = head_state(repo)?;
let mut selected = BTreeSet::new();
for s in [&state, &head] {
selected.extend(
s.entries()
.iter()
.map(|e| e.path(s).to_str_lossy().into_owned())
.filter(|s| matches(s, &p.paths)),
);
}
state.remove_entries(|_, path, _| selected.contains(path.to_str().unwrap_or("")));
for e in head.entries() {
let path = e.path(&head);
if selected.contains(path.to_str().unwrap_or("")) {
state.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, path);
}
}
// A partial reset can otherwise create a D/F conflict with unrelated staging.
check_structure(&state)?;
publish(repo, state)?;
Ok(StagedPaths {
paths: selected.into_iter().collect(),
})
}
fn check_structure(state: &State) -> Result<()> {
let paths: BTreeSet<_> = state
.entries()
.iter()
.map(|e| e.path(state).to_str_lossy().into_owned())
.collect();
for path in &paths {
for parent in Path::new(path).ancestors().skip(1).filter_map(Path::to_str) {
if paths.contains(parent) {
return Err(error("index", "file/directory conflict; select both paths"));
}
}
}
Ok(())
}
pub(crate) fn rm(repo: &gix::Repository, p: RmParams) -> Result<StagedPaths> {
validate_paths(&p.paths)?;
let mut state = load(repo)?;
let root = repo.workdir().ok_or_else(|| error("rm", "no worktree"))?;
let selected: BTreeSet<_> = state
.entries()
.iter()
.map(|e| e.path(&state).to_str_lossy().into_owned())
.filter(|s| matches(s, &p.paths))
.collect();
for path in &p.paths {
if !selected
.iter()
.any(|s| matches(s, std::slice::from_ref(path)))
{
return Err(error("rm", format!("path is not tracked: {path}")));
}
}
if !p.keep_file.unwrap_or(false) {
let head = head_state(repo)?;
// Preflight every path before deleting any file. Do not delete staged or local-only work.
for path in &selected {
safe(root, path)?;
for e in state
.entries()
.iter()
.filter(|e| e.path(&state) == path.as_bytes().as_bstr())
{
if e.stage_raw() != 0
|| !head.entries().iter().any(|h| {
h.path(&head) == e.path(&state) && h.id == e.id && h.mode == e.mode
})
{
return Err(error(
"rm",
format!("staged changes: {path}; use keep_file to preserve bytes"),
));
}
if let Some((_, id, mode)) = work_entry(repo, path, Some(e.mode))? {
if id != e.id || mode != e.mode {
return Err(error("rm", format!("local changes: {path}; use keep_file")));
}
} else if meta(&root.join(path))?.is_some() {
return Err(error("rm", format!("obstructing directory: {path}")));
}
if !matches!(e.mode, Mode::FILE | Mode::FILE_EXECUTABLE | Mode::SYMLINK) {
return Err(error("rm", "submodule removal unsupported"));
}
}
}
}
state.remove_entries(|_, path, _| selected.contains(path.to_str().unwrap_or("")));
// Reserve and fully serialize index.lock before any destructive worktree operation.
let pending = prepare(repo, state)?;
if !p.keep_file.unwrap_or(false) {
for path in &selected {
if meta(&root.join(path))?.is_some() {
fs::remove_file(root.join(path)).map_err(|e| error("rm file", e))?;
}
}
}
pending.commit()?;
Ok(StagedPaths {
paths: selected.into_iter().collect(),
})
}
pub(crate) fn commit(repo: &gix::Repository, p: CommitParams) -> Result<CommitResult> {
if p.message.trim().is_empty() || p.message.contains('\0') {
return Err(error(
"commit",
"message must be nonblank and contain no NUL",
));
}
if repo.git_dir().join("rebase-merge").exists() || repo.git_dir().join("rebase-apply").exists()
{
return Err(error(
"commit",
"rebase in progress; use rebase_continue after staging",
));
}
let override_author = p.author_name.is_some() || p.author_email.is_some();
let sig = signature(p.author_name, p.author_email)?;
let merge_parent = crate::merge::pending_parent(repo)?;
if merge_parent.is_some() && p.amend.unwrap_or(false) {
return Err(error("commit", "cannot amend during a merge"));
}
let head = repo.head().map_err(|e| error("commit HEAD", e))?;
let old = head.id().map(|id| id.detach());
let amend = p.amend.unwrap_or(false);
if amend && old.is_none() {
return Err(error("commit amend", "HEAD is unborn"));
}
let previous = old
.map(|id| repo.find_commit(id).map_err(|e| error("commit HEAD", e)))
.transpose()?;
let mut parents: Vec<_> = if amend {
previous
.as_ref()
.into_iter()
.flat_map(|c| c.parent_ids().map(|id| id.detach()))
.collect()
} else {
old.into_iter().collect()
};
if let Some(id) = merge_parent {
parents.push(id);
}
let mut state = load(repo)?;
if state
.entries()
.iter()
.any(|e| e.stage_raw() != 0 || e.flags.contains(Flags::INTENT_TO_ADD))
{
return Err(error(
"commit",
"unmerged or intent-to-add index; resolve/stage paths first",
));
}
if p.all.unwrap_or(false) {
let selected = state
.entries()
.iter()
.filter(|e| e.mode != Mode::COMMIT)
.map(|e| e.path(&state).to_str_lossy().into_owned())
.collect();
state = stage(repo, state, selected)?;
}
let tree = tree_from_index(repo, &state)?;
let empty = gix::ObjectId::empty_tree(repo.object_hash());
let base = if let Some(id) = parents.first() {
repo.find_commit(*id)
.map_err(|e| error("commit parent", e))?
.tree_id()
.map_err(|e| error("commit parent tree", e))?
.detach()
} else {
empty
};
if tree == base && merge_parent.is_none() && !p.allow_empty.unwrap_or(false) {
return Err(error(
"commit",
"empty commit refused; set allow_empty=true",
));
}
let author = if amend && !override_author {
previous
.as_ref()
.ok_or_else(|| error("amend", "missing HEAD"))?
.decode()
.map_err(|e| error("amend author", e))?
.author()
.map_err(|e| error("amend author", e))?
.to_owned()
.map_err(|e| error("amend author", e))?
} else {
sig.clone()
};
let object = gix::objs::Commit {
tree,
parents: parents.into_iter().collect(),
author,
committer: sig.clone(),
encoding: None,
message: p.message.clone().into(),
extra_headers: vec![],
};
let id = repo
.write_object(&object)
.map_err(|e| error("commit object", e))?
.detach();
if p.all.unwrap_or(false) {
publish(repo, state)?;
}
update_head(
repo,
old,
id,
&sig,
&format!(
"commit{}: {}",
if amend {
" (amend)"
} else if old.is_none() {
" (initial)"
} else {
""
},
p.message.lines().next().unwrap_or("").replace('\r', "")
),
)?;
if merge_parent.is_some() {
crate::merge::clear(repo)?;
}
Ok(CommitResult {
id: id.to_string(),
head: crate::head_info(repo)?,
warnings: if head.is_detached() {
vec!["commit created on detached HEAD; no branch was advanced".into()]
} else {
vec![]
},
})
}
/// Serialize the resolved index without updating any reference (also used by rebase).
pub(crate) fn tree_from_index(repo: &gix::Repository, state: &State) -> Result<gix::ObjectId> {
if state
.entries()
.iter()
.any(|e| e.stage_raw() != 0 || e.flags.contains(Flags::INTENT_TO_ADD))
{
return Err(error(
"commit",
"unmerged or intent-to-add index; resolve/stage paths first",
));
}
check_structure(state)?;
let empty = repo
.write_object(&gix::objs::Tree { entries: vec![] })
.map_err(|e| error("commit empty tree", e))?
.detach();
let tree = repo.find_tree(empty).map_err(|e| error("commit tree", e))?;
let mut editor =
gix::object::tree::Editor::new(&tree).map_err(|e| error("commit tree editor", e))?;
for e in state.entries() {
use gix::objs::tree::EntryKind;
let kind = match e.mode {
Mode::FILE => EntryKind::Blob,
Mode::FILE_EXECUTABLE => EntryKind::BlobExecutable,
Mode::SYMLINK => EntryKind::Link,
Mode::COMMIT => EntryKind::Commit,
_ => return Err(error("commit", "unsupported index mode")),
};
editor
.upsert(e.path(&state), kind, e.id)
.map_err(|e| error("commit tree entry", e))?;
}
let tree = editor
.write()
.map_err(|e| error("commit tree write", e))?
.detach();
Ok(tree)
}
pub(crate) fn signature(
name: Option<String>,
email: Option<String>,
) -> Result<gix::actor::Signature> {
let identity = crate::identity(name, email)?;
let name = identity
.name
.ok_or_else(|| error("identity", "missing name"))?;
let email = identity
.email
.ok_or_else(|| error("identity", "missing email"))?;
if [&name, &email]
.iter()
.any(|s| s.contains(['\n', '\r', '<', '>', '\0']))
{
return Err(error(
"identity",
"name/email must not contain newlines, NUL or angle brackets",
));
}
let seconds = std::env::var("GIT_AUTHOR_DATE")
.ok()
.and_then(|s| {
s.split_whitespace()
.next()?
.trim_start_matches("@")
.parse()
.ok()
})
.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
});
Ok(gix::actor::Signature {
name: name.into(),
email: email.into(),
time: gix::date::Time::new(seconds, 0),
})
}
// Separate from parent selection: amend must compare against the replaced commit, not its parent.
pub(crate) fn update_head(
repo: &gix::Repository,
old: Option<gix::ObjectId>,
new: gix::ObjectId,
sig: &gix::actor::Signature,
message: &str,
) -> Result<()> {
use gix::refs::{
Target,
transaction::{Change, LogChange, PreviousValue, RefEdit},
};
let mut time = Default::default();
repo.edit_references_as(
[RefEdit {
name: "HEAD"
.try_into()
.map_err(|e| error("commit HEAD name", e))?,
deref: true,
change: Change::Update {
expected: old
.map(|id| PreviousValue::MustExistAndMatch(Target::Object(id)))
.unwrap_or(PreviousValue::MustNotExist),
new: Target::Object(new),
log: LogChange {
message: message.into(),
force_create_reflog: true,
..Default::default()
},
},
}],
Some(sig.to_ref(&mut time)),
)
.map_err(|e| error("commit reference compare-and-swap", e))?;
Ok(())
}
+447
View File
@@ -0,0 +1,447 @@
//! Linked worktrees use ordinary Git files. Callers hold only the common-dir lock.
use crate::Result;
use crate::{
branches,
repo::{self, error},
};
use std::{
collections::BTreeSet,
fs,
io::Write,
path::{Path, PathBuf},
};
/// Attach a linked worktree, or adopt existing bytes onto an unborn branch.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct AddParams {
/// Repository name or absolute worktree/bare path under /repos/ or /plugins/.
pub repo: String,
/// Absolute worktree path under /plugins/ or /repos/, without dot segments.
pub path: String,
/// Short local branch name; an existing branch must be checked out nowhere.
pub branch: String,
/// Starting revision for a new branch, default HEAD. Not used for existing branches; forbidden with adopt.
pub from: Option<String>,
/// Attach an existing directory (including empty) to a new unborn branch; only .git is written.
pub adopt: Option<bool>,
}
/// Delete a linked worktree and its private metadata, never the common repository.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct RemoveParams {
/// Absolute linked-worktree root under /plugins/ or /repos/.
pub repo: String,
/// Discard dirty, untracked and pending operation state, default false.
pub force: Option<bool>,
}
/// Linked worktree identity.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct WorktreeInfo {
/// Absolute worktree root.
pub path: String,
/// Private Git metadata directory.
pub admin: String,
/// Checkout feature warnings, empty for adoption and removal.
pub warnings: Vec<String>,
}
fn canonical(path: &Path) -> Result<PathBuf> {
fs::canonicalize(path).map_err(|e| error("worktree path", format!("{}: {e}", path.display())))
}
fn read(path: &Path) -> Result<String> {
fs::read_to_string(path)
.map_err(|e| error("worktree metadata", format!("{}: {e}", path.display())))
}
fn main_worktree(common: &Path) -> Result<Option<PathBuf>> {
let main = gix::open_opts(common, gix::open::Options::default().open_path_as_is(true))
.map_err(|e| error("main worktree", e))?;
Ok(main.workdir().map(Path::to_owned))
}
/// Scan symbolic HEADs, including unborn branches. Ignore only this handle's private HEAD.
/// A bare common HEAD is a default branch, not a checked-out worktree.
pub(crate) fn ensure_available(repo: &gix::Repository, branch: &str) -> Result<()> {
let common = canonical(repo.common_dir())?;
let current = canonical(repo.git_dir())?;
let mut heads = Vec::new();
if let Some(path) = main_worktree(&common)? {
heads.push((common.clone(), path));
}
let entries = match fs::read_dir(common.join("worktrees")) {
Ok(entries) => Some(entries),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(error("worktree scan", e)),
};
if let Some(entries) = entries {
for entry in entries {
let entry = entry.map_err(|e| error("worktree scan", e))?;
if !entry
.file_type()
.map_err(|e| error("worktree scan", e))?
.is_dir()
{
continue;
}
let admin = entry.path();
let gitfile = read(&admin.join("gitdir"))?;
let gitfile = PathBuf::from(gitfile.trim());
let path = gitfile
.parent()
.ok_or_else(|| error("worktree gitdir", "missing worktree path"))?
.to_owned();
heads.push((admin, path));
}
}
for (admin, path) in heads {
if canonical(&admin)? == current {
continue;
}
let head = read(&admin.join("HEAD"))?;
if head.trim().strip_prefix("ref: ") == Some(branch) {
return Err(error(
"branch checked out",
format!("{branch} is checked out at {}", path.display()),
));
}
}
Ok(())
}
fn absolute(path: &str) -> Result<PathBuf> {
if !path.starts_with('/') || path.contains(['\r', '\n']) {
return Err(error(
"worktree path",
"expected an absolute path without line breaks",
));
}
repo::repo_path(path)
}
fn separate(path: &Path, common: &Path) -> Result<()> {
if path.starts_with(common) || common.starts_with(path) {
return Err(error(
"worktree path",
"worktree and common repository must not contain one another",
));
}
Ok(())
}
fn write_new(path: &Path, bytes: &str) -> Result<()> {
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|e| error("worktree create", format!("{}: {e}", path.display())))?;
if let Err(e) = file.write_all(bytes.as_bytes()) {
drop(file);
fs::remove_file(path).map_err(|cleanup| error("worktree write cleanup", cleanup))?;
return Err(error("worktree write", e));
}
Ok(())
}
/// Attach existing bytes to an existing branch without checking out any files.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct AttachParams {
/// Bare common repository path.
pub repo: String,
/// Existing worktree directory; only .git metadata is written.
pub path: String,
/// Existing short local branch name.
pub branch: String,
}
pub(crate) fn attach(repo: &gix::Repository, p: AttachParams) -> Result<WorktreeInfo> {
add_with(
repo,
AddParams {
repo: p.repo,
path: p.path,
branch: p.branch,
from: None,
adopt: None,
},
true,
)
}
pub(crate) fn add(repo: &gix::Repository, p: AddParams) -> Result<WorktreeInfo> {
add_with(repo, p, false)
}
fn add_with(repo: &gix::Repository, p: AddParams, keep_files: bool) -> Result<WorktreeInfo> {
let path = absolute(&p.path)?;
let common = canonical(repo.common_dir())?;
separate(&path, &common)?;
let branch = branches::branch_name(&p.branch)?;
ensure_available(repo, &branch)?;
if let Some(current_path) = repo.workdir() {
if crate::head_info(repo)?.branch.as_deref() == Some(&branch) {
return Err(error(
"branch checked out",
format!("{branch} is checked out at {}", current_path.display()),
));
}
}
let exists = repo
.try_find_reference(branch.as_str())
.map_err(|e| error("worktree branch", e))?
.is_some();
let adopt = p.adopt.unwrap_or(false);
if adopt && (exists || p.from.is_some()) {
return Err(error(
"worktree adopt",
"requires a new unborn branch and no from revision",
));
}
let existed = path.try_exists().map_err(|e| error("worktree path", e))?;
if existed && !path.is_dir() {
return Err(error("worktree path", "not a directory"));
}
let nonempty = if existed {
fs::read_dir(&path)
.map_err(|e| error("worktree directory", e))?
.next()
.transpose()
.map_err(|e| error("worktree directory", e))?
.is_some()
} else {
false
};
if adopt && !existed {
return Err(error("worktree adopt", "requires an existing directory"));
}
if keep_files && !exists {
return Err(error("worktree attach", "requires an existing branch"));
}
if keep_files && !existed {
return Err(error("worktree attach", "requires an existing directory"));
}
if !adopt && !keep_files && nonempty {
return Err(error(
"worktree path",
format!("{} is nonempty", path.display()),
));
}
if path.join(".git").exists() {
return Err(error("worktree adopt", ".git already exists"));
}
let id_path = if path.file_name().is_some_and(|s| s == "source") {
path.parent().unwrap_or(&path)
} else {
&path
};
let id = id_path
.file_name()
.ok_or_else(|| error("worktree id", "missing folder name"))?;
let admin = common.join("worktrees").join(id);
// Resolve before any mutation. Existing branches ignore from, matching checkout semantics.
let start = if adopt {
None
} else {
Some(branches::resolve(
repo,
if exists {
&branch
} else {
p.from.as_deref().unwrap_or("HEAD")
},
)?)
};
fs::create_dir_all(common.join("worktrees")).map_err(|e| error("worktree admin", e))?;
fs::create_dir(&admin)
.map_err(|e| error("worktree id clash", format!("{}: {e}", admin.display())))?;
let mut gitfile_created = false;
let result: Result<WorktreeInfo> = (|| {
fs::create_dir_all(&path).map_err(|e| error("worktree mkdir", e))?;
separate(&canonical(&path)?, &common)?;
// Start detached to let checkout create/attach the branch after index publication.
fs::write(
admin.join("HEAD"),
start
.map(|id| format!("{id}\n"))
.unwrap_or_else(|| format!("ref: {branch}\n")),
)
.map_err(|e| error("worktree HEAD", e))?;
fs::write(admin.join("commondir"), "../..\n")
.map_err(|e| error("worktree commondir", e))?;
fs::write(admin.join("gitdir"), format!("{}/.git\n", path.display()))
.map_err(|e| error("worktree gitdir", e))?;
write_new(
&path.join(".git"),
&format!("gitdir: {}\n", admin.display()),
)?;
gitfile_created = true;
let linked = repo::open(&p.path)?;
let warnings = if let Some(id) = start {
let tree = linked
.find_commit(id)
.map_err(|e| error("worktree commit", e))?
.tree_id()
.map_err(|e| error("worktree tree", e))?
.detach();
if keep_files {
let state =
gix::index::State::from_tree(&tree, &linked.objects, Default::default())
.map_err(|e| error("worktree attach index", e))?;
crate::staging::publish(&linked, state)?;
fs::write(admin.join("HEAD"), format!("ref: {branch}\n"))
.map_err(|e| error("worktree attach HEAD", e))?;
return Ok(WorktreeInfo {
path: p.path.clone(),
admin: admin.to_string_lossy().into_owned(),
warnings: vec![],
});
}
let mut warnings = crate::checkout::write_tree_to_worktree(&linked, None, tree, false)?;
warnings.extend(
branches::checkout(
&linked,
branches::CheckoutParams {
repo: p.path.clone(),
target: p.branch.clone(),
create: Some(!exists),
force: None,
},
)?
.warnings,
);
warnings
} else {
Vec::new()
};
Ok(WorktreeInfo {
path: p.path.clone(),
admin: admin.to_string_lossy().into_owned(),
warnings,
})
})();
if result.is_err() {
// Adoption never removes source bytes. Non-adoption started empty, so all leaves are ours.
let cleanup = (|| -> std::io::Result<()> {
if adopt || keep_files {
if gitfile_created {
fs::remove_file(path.join(".git"))?;
}
} else if gitfile_created {
fs::remove_dir_all(&path)?;
if existed {
fs::create_dir(&path)?;
}
} else if !existed && path.is_dir() {
fs::remove_dir(&path)?;
}
Ok(())
})();
let admin_cleanup = fs::remove_dir_all(&admin);
if let Err(e) = cleanup.and(admin_cleanup) {
return Err(error(
"worktree rollback",
format!("{}; original error: {}", e, result.err().unwrap().message),
));
}
}
result
}
pub(crate) fn remove(repo: &gix::Repository, p: RemoveParams) -> Result<WorktreeInfo> {
let path = canonical(&absolute(&p.repo)?)?;
let common = canonical(repo.common_dir())?;
let admin = canonical(repo.git_dir())?;
separate(&path, &common)?;
// Only linked metadata directly under common/worktrees is removable, even with force.
if admin.parent() != Some(common.join("worktrees").as_path()) || !path.join(".git").is_file() {
return Err(error(
"worktree remove",
"cannot remove the original/main repository worktree",
));
}
let backlink = PathBuf::from(read(&admin.join("gitdir"))?.trim());
if canonical(&backlink)? != canonical(&path.join(".git"))? {
return Err(error(
"worktree remove",
"admin gitdir does not point back to this worktree",
));
}
if !p.force.unwrap_or(false) {
let s = crate::changes::status(repo)?;
let mut paths: BTreeSet<_> = s
.staged
.into_iter()
.chain(s.unstaged)
.flat_map(|c| std::iter::once(c.path).chain(c.old_path))
.chain(s.untracked)
.chain(s.conflicted)
.collect();
if let Err(e) = crate::merge::ensure_idle(repo) {
paths.insert(e.message);
}
if repo.git_dir().join("index.lock").exists() {
paths.insert("index.lock".into());
}
// Include ignored untracked files too: removing a worktree must not silently lose them.
let index = repo
.index_or_empty()
.map_err(|e| error("worktree index", e))?;
let tracked: BTreeSet<_> = index
.entries()
.iter()
.map(|e| e.path(&index).to_vec())
.collect();
collect_untracked(&path, &path, &tracked, &mut paths)?;
if !paths.is_empty() {
return Err(error(
"worktree remove dirty/untracked/pending paths",
paths.into_iter().collect::<Vec<_>>().join(", "),
));
}
}
fs::remove_dir_all(&path).map_err(|e| error("worktree remove files", e))?;
fs::remove_dir_all(&admin).map_err(|e| error("worktree remove admin", e))?;
if let Some(parent) = path.parent() {
if parent.parent() == Some(Path::new("/plugins"))
&& parent
.file_name()
.is_some_and(|s| s.to_string_lossy().starts_with("wt-"))
{
match fs::remove_dir(parent) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty => {}
Err(e) => return Err(error("worktree remove empty parent", e)),
}
}
}
Ok(WorktreeInfo {
path: p.repo,
admin: admin.to_string_lossy().into_owned(),
warnings: vec![],
})
}
fn collect_untracked(
root: &Path,
dir: &Path,
tracked: &BTreeSet<Vec<u8>>,
paths: &mut BTreeSet<String>,
) -> Result<()> {
for entry in fs::read_dir(dir).map_err(|e| error("worktree directory", e))? {
let entry = entry.map_err(|e| error("worktree directory", e))?;
let path = entry.path();
if path == root.join(".git") {
continue;
}
if entry
.file_type()
.map_err(|e| error("worktree directory", e))?
.is_dir()
{
collect_untracked(root, &path, tracked, paths)?;
} else {
let relative = path
.strip_prefix(root)
.map_err(|e| error("worktree path", e))?
.to_string_lossy();
if !tracked.contains(relative.as_bytes()) {
paths.insert(relative.into_owned());
}
}
}
Ok(())
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env nu
# Compare a deterministic agent workflow with native Git. Fail on every difference.
def main [component: path] {
let component = ($component | path expand)
let wit = (^wasm-tools component wit $component | complete)
if $wit.exit_code != 0 {error make {msg: $wit.stderr}}
let imports = ($wit.stdout | lines | str trim | where {|line| $line | str starts-with 'import '})
let forbidden = ($imports | where {|line| not ($line | str starts-with 'import wasi:')})
if ($imports | is-empty) or not ($forbidden | is-empty) {error make {msg: $'Unexpected component imports: ($imports)'}}
print 'Component imports: PASS (WASI only)'
print ($imports | str join (char newline))
let root = (mktemp -d -p /tmp/p1-git workflow.XXXXXX)
print $'Fixture: ($root)'
let envs = {GIT_AUTHOR_NAME: 'Agent', GIT_AUTHOR_EMAIL: 'agent@example.test', GIT_COMMITTER_NAME: 'Agent', GIT_COMMITTER_EMAIL: 'agent@example.test', GIT_AUTHOR_DATE: '1700000000 +0000', GIT_COMMITTER_DATE: '1700000000 +0000', GIT_CONFIG_NOSYSTEM: '1', GIT_EDITOR: 'true', HOME: '/tmp/p1-git/empty-home', LC_ALL: 'C', TZ: 'UTC'}
mkdir $envs.HOME
with-env $envs {
for side in [native wasi] { mkdir ($root | path join $side repo) }
let steps = [
{args: [init -b main], compare: false}
{args: [config user.name Agent], compare: true}
{args: [config user.email agent@example.test], compare: true}
{write: 'base\n', file: 'file.txt'}
{args: [add .], compare: true}
{args: [commit -m initial], compare: true}
{args: [status --short], compare: true}
{args: [status], compare: true}
{args: [log -n 1], compare: true}
{args: [show HEAD], compare: true}
{args: [show HEAD:file.txt], compare: true}
{args: [branch topic], compare: true}
{args: [switch topic], compare: true}
{write: 'topic\n', file: 'topic.txt'}
{args: [add .], compare: true}
{args: [commit -m topic], compare: true}
{args: [switch main], compare: true}
{args: [merge topic], compare: true}
{write: 'changed\n', file: 'file.txt'}
{args: [status --short], compare: true}
{args: [diff], compare: true}
{args: [diff --stat], compare: true}
{args: [status], compare: true}
{args: [commit -am changed], compare: true}
{args: [log --oneline], compare: true}
{args: [switch topic], compare: true}
{write: 'conflict\n', file: 'file.txt'}
{args: [commit -am conflict], compare: true}
{args: [switch main], compare: true}
{args: [merge topic], compare: true, exit: 1}
{args: [status --short], compare: true}
{args: [merge --abort], compare: true}
{args: [worktree add -b linked ../linked], compare: true}
{args: [worktree list], compare: false}
{args: [worktree remove ../linked], compare: true}
{args: [switch topic], compare: true}
{args: [rebase main], compare: true, exit: 1}
{write: 'resolved\n', file: 'file.txt'}
{args: [add file.txt], compare: true}
{args: [rebase --continue], compare: true}
{args: [log --oneline], compare: true}
{args: [switch main], compare: true}
{write: 'left\n', file: 'left.txt'}
{args: [add .], compare: true}
{args: [commit -m left], compare: true}
{args: [switch topic], compare: true}
{write: 'right\n', file: 'right.txt'}
{args: [add .], compare: true}
{args: [commit -m right], compare: true}
{args: [switch main], compare: true}
{args: [merge topic], compare: true}
{args: [branch], compare: true}
{args: [rm right.txt], compare: true}
{args: [diff --cached], compare: true}
{args: [status --porcelain], compare: true}
{args: [commit -m removed], compare: true}
{args: [status --bad-option], compare: true, exit: 129}
{args: [not-a-command], compare: true, exit: 1}
]
mut failures = 0
for step in $steps {
if 'write' in $step {
for side in [native wasi] { $step.write | str replace --all '\n' (char newline) | save --force ($root | path join $side repo $step.file) }
continue
}
let args = $step.args
let native = (^git -C ($root | path join native repo) ...$args | complete)
let wasi = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($root)/wasi::/' --env GIT_AUTHOR_NAME --env GIT_AUTHOR_EMAIL --env GIT_COMMITTER_NAME --env GIT_COMMITTER_EMAIL --env GIT_AUTHOR_DATE --env GIT_COMMITTER_DATE $component -C /repo ...$args | complete)
let expected = ($step.exit? | default 0)
let equal = ($native.exit_code == $expected and $wasi.exit_code == $expected and (not $step.compare or $native.stdout == $wasi.stdout))
print $'($args | str join " "): (if $equal {"PASS"} else {"FAIL"}) native=($native.exit_code) wasi=($wasi.exit_code)'
if not $equal {
$failures += 1
print ({native: $native, wasi: $wasi} | to json)
}
}
if $failures > 0 {error make {msg: $'($failures) workflow comparisons failed'}}
}
}
@@ -0,0 +1,5 @@
[package]
name = "git-test-http-server"
version = "0.1.0"
edition = "2024"
[workspace]
@@ -0,0 +1,86 @@
//! A local HTTP CGI server for integration tests against native git http-backend.
use std::{
io::{Read, Write},
net::TcpListener,
process::{Command, Stdio},
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().collect();
let listener = TcpListener::bind("127.0.0.1:0")?;
std::fs::write(&args[2], listener.local_addr()?.port().to_string())?;
for stream in listener.incoming() {
let mut stream = stream?;
if let Err(error) = serve(&mut stream, &args[1]) {
eprintln!("request failed: {error}");
}
}
Ok(())
}
fn serve(io: &mut (impl Read + Write), root: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut header = Vec::new();
while !header.ends_with(b"\r\n\r\n") {
let mut byte = [0];
io.read_exact(&mut byte)?;
header.push(byte[0]);
}
let text = std::str::from_utf8(&header)?;
let mut lines = text.split("\r\n");
let request: Vec<_> = lines
.next()
.ok_or("no request")?
.split_whitespace()
.collect();
let mut length = 0;
let mut content_type = "";
for line in lines {
if let Some((key, value)) = line.split_once(':') {
if key.eq_ignore_ascii_case("content-length") {
length = value.trim().parse()?;
}
if key.eq_ignore_ascii_case("content-type") {
content_type = value.trim();
}
}
}
let mut body = vec![0; length];
io.read_exact(&mut body)?;
let (path, query) = request[1].split_once('?').unwrap_or((request[1], ""));
let mut child = Command::new("git")
.arg("http-backend")
.env("GIT_PROJECT_ROOT", root)
.env("GIT_HTTP_EXPORT_ALL", "1")
.env("PATH_INFO", path)
.env("QUERY_STRING", query)
.env("REQUEST_METHOD", request[0])
.env("CONTENT_TYPE", content_type)
.env("CONTENT_LENGTH", length.to_string())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
child.stdin.take().ok_or("no CGI stdin")?.write_all(&body)?;
let response = child.wait_with_output()?;
let split = response
.stdout
.windows(4)
.position(|w| w == b"\r\n\r\n")
.ok_or("invalid CGI response")?;
let headers = std::str::from_utf8(&response.stdout[..split])?;
let status = headers
.lines()
.find_map(|l| l.strip_prefix("Status: "))
.unwrap_or("200 OK");
write!(
io,
"HTTP/1.1 {status}\r\nConnection: close\r\nContent-Length: {}\r\n",
response.stdout.len() - split - 4
)?;
for line in headers.split("\r\n") {
if !line.starts_with("Status:") {
write!(io, "{line}\r\n")?;
}
}
io.write_all(b"\r\n")?;
io.write_all(&response.stdout[split + 4..])?;
io.flush()?;
Ok(())
}
+137
View File
@@ -0,0 +1,137 @@
# WASI patches for Git foundation (SAU-21)
All crates here are complete crates.io source archives, with their original licenses.
`Cargo.toml` uses local `[patch.crates-io]` paths. No upstream submission has been made.
Changes apply only to `target_os = "wasi"`; native paths retain upstream behavior.
No parallel, signal/interrupt, or worktree-mutation features are enabled.
SAU-24 enables blocking-network-client and a custom wasi:http backend (no curl/reqwest).
## gix-tempfile 26.0.0: no OS PID on WASI
Exact panic: `src/forksafe.rs`, `ForksafeTempfile::new`, field
`owning_process_id: std::process::id()`. Ref transactions acquire gix-lock files,
which register these tempfiles. This is registry fork ownership, **not filename generation**.
`std::process::id()` panics with `no pids on this platform` on wasm32-wasip2.
Patch: a private `process_id()` returns 0 on WASI (no fork, registry is per component
instance), and the native OS PID elsewhere. Use it in creation and both registry
cleanup methods. Random tempfile naming and exclusive file creation are unchanged.
Verified the unpatched ref-updating commit panics; patched commits and index writes pass.
## gix-pack 0.77.0: owned pack/index/multi-index bytes
`src/lib.rs`: WASI's `MMap` is `Vec<u8>` and the shared `mmap::read_only()` helper
uses `std::fs::read`. Other targets retain memmap2. This covers the pack data,
pack index, and multi-pack-index constructors that all use this helper.
memmap2 0.9.11 selects its unsupported stub on WASI; the original map call returns
`ErrorKind::Unsupported` rather than reading a pack.
Tests generate a tiny pack via `gix-pack`'s `data::output::bytes::FromEntriesIter`;
the simple v2 index is encoded in the test (fanout, sorted IDs, CRCs, offsets,
pack checksum, index checksum). This avoids enabling the broken streaming-input
feature just for the test. A second test packs all commit/tree/blob objects,
removes every loose object copy and stores the branch only in `packed-refs`,
reopens the repository and reads history, paths, branches,
blob contents, and creates/reads its index. No shell or Git executable is used.
## gix-odb 0.87.0: owned loose-object bytes
Additional breakage found by the full tests: `src/store_impls/loose/find.rs`
uses memmap2 for *every loose object read and header lookup*, not just packs.
Without this patch logs and tree/index construction return `unsupported`.
Use the same platform-dependent `gix_pack::MMap` backing and std file reads on WASI.
Decompression, missing-object handling, and validation remain upstream code.
## gix-index 0.58.0: read index without mmap
`src/file/init.rs`: WASI reads into a Vec from the already opened file and rewinds
that same handle before checksum streaming. This preserves a single inode snapshot
across concurrent atomic index replacements. Native mmap code is unchanged.
Nonempty v2 index round trip and checksum-corruption rejection pass under WASI.
## filetime 0.2.29: metadata timestamp readers
Additional runtime panic found after the mmap fixes: filetime routes WASI to its
generic wasm backend, whose metadata timestamp readers call `unimplemented!()`.
`src/lib.rs` now selects `src/wasi.rs` before that backend. It obtains modified,
accessed, and optional creation times through std Metadata; inaccessible modified/
accessed times fall back to zero, because the upstream API cannot return an error.
Symlink time mutation remains an explicit Unsupported error; ordinary file-time
setters retain the upstream std File::set_times implementation. Metadata reads are
exercised by index tests; timestamp setters and symlink mutation are not exercised.
## gix-ref 0.69.0: disable packed-refs mmap through existing API
No additional vendored crate is needed. On WASI, `src/repo.rs::open` calls
`repo.refs.set_packed_buffer_mmap_threshold(u64::MAX)` before any reference lookup.
Upstream `store/packed/buffer.rs::Buffer::open` uses owned bytes for files at or
below that threshold; otherwise it uses unsupported memmap2. The default threshold
is 32 KiB. Native repository handles retain the upstream default.
Regression `large_packed_refs_head_branches_and_log` writes 65,638 bytes of sorted
packed refs (1,025 branches), removes the loose HEAD branch and reopens through
our helper. It checks symbolic HEAD, all branch tips/current flag, HEAD log order,
and log by a non-HEAD packed branch. Before the fix this fails on WASI with
`HEAD: Could not open packed refs`; with the fix it passes. Packed refs also consume
owned linear memory; this is not a repository size limit.
## Intentionally disabled / work for later cards
- `worktree-mutation`: `gix-worktree-state` depends on `io-close 0.3.7`; it lacks
a WASI File close implementation (`no method close for File`). Add a WASI backend
or write the checkout engine using std file writes. Do not silently ignore writes.
- `blocking-network-client` is now enabled by SAU-24: see the streaming-input patch below.
- `gix-commitgraph` remains unpatched: `src/file/init.rs` maps commit-graph files
with memmap2, unsupported on WASI. Current `log` explicitly uses
`use_commit_graph(false)` and does not need this cache. This is NOT disabled
centrally by `repo::open`. Later merge-base/ahead-behind/revision operations must
disable or avoid commit-graph loading (including direct graph APIs), or patch
that crate to read owned bytes before using it. Some high-level gix walks fall
back to the object database on graph-load errors; direct graph loading can fail.
Existing native Git `objects/info/commit-graph` files are not fixture-tested here.
- No signals or threads are provided; keep `interrupt` and `parallel` off.
- No large-repo memory benchmark was run. Pack + pack-index bytes are held in
32-bit linear memory, in addition to decoded objects; whole-pack reads have a
materially higher resident-memory cost than demand-paged native mmap. The tiny
fixture is 53 pack bytes for one blob. No peak RSS/linear-memory measurement.
- Multi-pack-index reading uses the patched helper but is not fixture-tested here.
Delta packs, split index, and external Git-generated fixtures are not tested.
## SAU-24: gix-pack 0.77.0 streaming-input on WASI
Existing vendored crate extended (no additional crate patch). In `Cargo.toml` and
`Cargo.toml.orig`, allow gix-tempfile under
`cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))`. Browser wasm is still
excluded; gix-tempfile remains `default-features = false`, so no signals are added.
The foundation's PID patch applies unchanged.
Additional compile error exposed by enabling the feature: bundle writer's
`new_pack_file_resolver` and `resolve_entry` explicitly used `memmap2::Mmap`.
Change those three type annotations in `src/bundle/write/mod.rs` to `crate::MMap`,
reusing the existing owned-byte WASI helper and keeping native mmap behavior.
No pack/index format change. No upstream submission made.
Verified by local fake Git HTTP servers through wasi:http in cargo.build: V1
clone writes a received 227-byte / 3-object pack and real index, updates tracking
refs and checks out exact bytes; V2 shallow clone writes the shallow boundary.
This tests the bundle writer, not merely hand-encoded pack indexes. Pack input
is streamed to a tempfile; indexing still reads a whole-pack Vec on WASI and
therefore needs 32-bit memory proportional to pack size. Use clone `depth` to
reduce history. No large-repo memory measurement.
## Sources inspected
- https://raw.githubusercontent.com/GitoxideLabs/gitoxide/gix-v0.90.0/gix-ref/src/store/file/mod.rs
- https://raw.githubusercontent.com/GitoxideLabs/gitoxide/gix-v0.90.0/gix-ref/src/store/packed/buffer.rs
- https://raw.githubusercontent.com/GitoxideLabs/gitoxide/gix-v0.90.0/gix/src/types.rs
- https://raw.githubusercontent.com/GitoxideLabs/gitoxide/gix-v0.90.0/gix-commitgraph/src/file/init.rs
- https://raw.githubusercontent.com/GitoxideLabs/gitoxide/gix-v0.90.0/gix/src/revision/walk.rs
- https://raw.githubusercontent.com/GitoxideLabs/gitoxide/gix-v0.90.0/gix/Cargo.toml
- https://static.crates.io/crates/gix-tempfile/gix-tempfile-26.0.0.crate
- https://static.crates.io/crates/gix-pack/gix-pack-0.77.0.crate
- https://static.crates.io/crates/gix-index/gix-index-0.58.0.crate
- https://static.crates.io/crates/gix-odb/gix-odb-0.87.0.crate
- https://raw.githubusercontent.com/RazrFalcon/memmap2-rs/v0.9.11/src/stub.rs
- https://raw.githubusercontent.com/alexcrichton/filetime/0.2.29/src/wasm.rs
- https://static.crates.io/crates/filetime/filetime-0.2.29.crate
+6
View File
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "ab5ee65b5e4fe2de19dbe7d4fe08bc31e945949c"
},
"path_in_vcs": ""
}
+86
View File
@@ -0,0 +1,86 @@
name: CI
on: [push, pull_request]
jobs:
test:
name: Test
runs-on: ${{ matrix.os }}
strategy:
matrix:
build: [stable, beta, nightly, macos, win32, win64, mingw]
include:
- build: stable
os: ubuntu-latest
rust: stable
- build: beta
os: ubuntu-latest
rust: beta
- build: nightly
os: ubuntu-latest
rust: nightly
- build: macos
os: macos-latest
rust: stable
- build: win32
os: windows-latest
rust: stable-i686
- build: win64
os: windows-latest
rust: stable-x86_64
- build: mingw
os: windows-latest
rust: stable-x86_64-gnu
steps:
- uses: actions/checkout@master
- name: Install Rust (rustup)
run: rustup update ${{ matrix.rust }} --no-self-update && rustup default ${{ matrix.rust }}
shell: bash
- run: cargo test
msrv:
name: MSRV
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Install Rust
run: rustup update 1.75.0 && rustup default 1.75.0
- run: cargo build
rustfmt:
name: Rustfmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Install Rust
run: rustup update stable && rustup default stable && rustup component add rustfmt
- run: cargo fmt -- --check
build:
name: Build for Android and Redox
runs-on: ubuntu-latest
strategy:
matrix:
target: [x86_64-linux-android, x86_64-unknown-redox]
steps:
- uses: actions/checkout@master
- name: Install Rust
run: rustup update stable && rustup default stable && rustup target add ${{ matrix.target }}
- run: cargo build --target ${{ matrix.target }}
publish_docs:
name: Publish Documentation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Install Rust
run: rustup update stable && rustup default stable
- name: Build documentation
run: cargo doc --no-deps --all-features
- name: Publish documentation
run: |
cd target/doc
git init
git add .
git -c user.name='ci' -c user.email='ci' commit -m init
git push -f -q https://git:${{ secrets.github_token }}@github.com/${{ github.repository }} HEAD:gh-pages
if: github.event_name == 'push' && github.event.ref == 'refs/heads/master'
+2
View File
@@ -0,0 +1,2 @@
target
Cargo.lock
+53
View File
@@ -0,0 +1,53 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2018"
rust-version = "1.75.0"
name = "filetime"
version = "0.2.29"
authors = ["Alex Crichton <alex@alexcrichton.com>"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = """
Platform-agnostic accessors of timestamps in File metadata
"""
homepage = "https://github.com/alexcrichton/filetime"
documentation = "https://docs.rs/filetime"
readme = "README.md"
keywords = [
"timestamp",
"mtime",
]
license = "MIT/Apache-2.0"
repository = "https://github.com/alexcrichton/filetime"
[lib]
name = "filetime"
path = "src/lib.rs"
[dependencies.cfg-if]
version = "1.0.0"
[dev-dependencies.tempfile]
version = "3"
[target."cfg(unix)".dependencies.libc]
version = "0.2.27"
[lints.rust.unexpected_cfgs]
level = "warn"
priority = 0
check-cfg = ["cfg(emulate_second_only_system)"]
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "filetime"
authors = ["Alex Crichton <alex@alexcrichton.com>"]
version = "0.2.29"
license = "MIT/Apache-2.0"
readme = "README.md"
keywords = ["timestamp", "mtime"]
repository = "https://github.com/alexcrichton/filetime"
homepage = "https://github.com/alexcrichton/filetime"
documentation = "https://docs.rs/filetime"
description = """
Platform-agnostic accessors of timestamps in File metadata
"""
edition = "2018"
rust-version = "1.75.0"
[dependencies]
cfg-if = "1.0.0"
[target.'cfg(unix)'.dependencies]
libc = "0.2.27"
[dev-dependencies]
tempfile = "3"
[lints.rust.unexpected_cfgs]
level = "warn"
check-cfg = [
'cfg(emulate_second_only_system)',
]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+25
View File
@@ -0,0 +1,25 @@
Copyright (c) 2014 Alex Crichton
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+45
View File
@@ -0,0 +1,45 @@
# filetime
[Documentation](https://docs.rs/filetime)
> **Note**: this library is quite old and superseded by functionality in the
> standard library. Nowadays you shouldn't use this crate and you should use
> [`FileTimes`] and [`File::set_times`] instead.
[`FileTimes`]: https://doc.rust-lang.org/stable/std/fs/struct.FileTimes.html
[`File::set_times`]: https://doc.rust-lang.org/stable/std/fs/struct.File.html#method.set_times
A helper library for inspecting and setting the various timestamps of files in
Rust. This library takes into account cross-platform differences in terms of
where the timestamps are located, what they are called, and how to convert them
into a platform-independent representation.
```toml
# Cargo.toml
[dependencies]
filetime = "0.2"
```
# Advantages over using `std::fs::Metadata`
This library, when built with `RUSTFLAGS=--cfg emulate_second_only_system` set,
will return all times rounded down to the second. This emulates the behavior of
some file systems, mostly [HFS](https://en.wikipedia.org/wiki/HFS_Plus),
allowing debugging on other hardware.
# License
This project is licensed under either of
* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or
http://www.apache.org/licenses/LICENSE-2.0)
* MIT license ([LICENSE-MIT](LICENSE-MIT) or
http://opensource.org/licenses/MIT)
at your option.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in Filetime by you, as defined in the Apache-2.0 license, shall be
dual licensed as above, without any additional terms or conditions.
+748
View File
@@ -0,0 +1,748 @@
//! Timestamps for files in Rust
//!
//! This library provides platform-agnostic inspection of the various timestamps
//! present in the standard `fs::Metadata` structure.
//!
//! # Installation
//!
//! Add this to your `Cargo.toml`:
//!
//! ```toml
//! [dependencies]
//! filetime = "0.2"
//! ```
//!
//! # Usage
//!
//! ```no_run
//! use std::fs;
//! use filetime::FileTime;
//!
//! let metadata = fs::metadata("foo.txt").unwrap();
//!
//! let mtime = FileTime::from_last_modification_time(&metadata);
//! println!("{}", mtime);
//!
//! let atime = FileTime::from_last_access_time(&metadata);
//! assert!(mtime < atime);
//!
//! // Inspect values that can be interpreted across platforms
//! println!("{}", mtime.unix_seconds());
//! println!("{}", mtime.nanoseconds());
//!
//! // Print the platform-specific value of seconds
//! println!("{}", mtime.seconds());
//! ```
use std::fmt;
use std::fs;
use std::io;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
cfg_if::cfg_if! {
if #[cfg(target_os = "redox")] {
#[path = "redox.rs"]
mod imp;
} else if #[cfg(windows)] {
#[path = "windows.rs"]
mod imp;
} else if #[cfg(target_os = "wasi")] {
#[path = "wasi.rs"]
mod imp;
} else if #[cfg(all(target_family = "wasm", not(target_os = "emscripten")))] {
#[path = "wasm.rs"]
mod imp;
} else {
#[path = "unix/mod.rs"]
mod imp;
}
}
/// A helper structure to represent a timestamp for a file.
///
/// The actual value contined within is platform-specific and does not have the
/// same meaning across platforms, but comparisons and stringification can be
/// significant among the same platform.
#[derive(Eq, PartialEq, Ord, PartialOrd, Debug, Copy, Clone, Hash)]
pub struct FileTime {
seconds: i64,
nanos: u32,
}
impl FileTime {
/// Creates a new timestamp representing a 0 time.
///
/// Useful for creating the base of a cmp::max chain of times.
pub const fn zero() -> FileTime {
FileTime {
seconds: 0,
nanos: 0,
}
}
const fn emulate_second_only_system(self) -> FileTime {
if cfg!(emulate_second_only_system) {
FileTime {
seconds: self.seconds,
nanos: 0,
}
} else {
self
}
}
/// Creates a new timestamp representing the current system time.
///
/// ```
/// # use filetime::FileTime;
/// #
/// # fn example() -> std::io::Result<()> {
/// # let path = "";
/// #
/// filetime::set_file_mtime(path, FileTime::now())?;
/// #
/// # Ok(())
/// # }
/// ```
///
/// Equivalent to `FileTime::from_system_time(SystemTime::now())`.
pub fn now() -> FileTime {
FileTime::from_system_time(SystemTime::now())
}
/// Creates a new instance of `FileTime` with a number of seconds and
/// nanoseconds relative to the Unix epoch, 1970-01-01T00:00:00Z.
///
/// Negative seconds represent times before the Unix epoch, and positive
/// values represent times after it. Nanos always count forwards in time.
///
/// Note that this is typically the relative point that Unix time stamps are
/// from, but on Windows the native time stamp is relative to January 1,
/// 1601 so the return value of `seconds` from the returned `FileTime`
/// instance may not be the same as that passed in.
pub const fn from_unix_time(seconds: i64, nanos: u32) -> FileTime {
FileTime {
seconds: seconds + if cfg!(windows) { 11644473600 } else { 0 },
nanos,
}
.emulate_second_only_system()
}
/// Creates a new timestamp from the last modification time listed in the
/// specified metadata.
///
/// The returned value corresponds to the `mtime` field of `stat` on Unix
/// platforms and the `ftLastWriteTime` field on Windows platforms.
pub fn from_last_modification_time(meta: &fs::Metadata) -> FileTime {
imp::from_last_modification_time(meta).emulate_second_only_system()
}
/// Creates a new timestamp from the last access time listed in the
/// specified metadata.
///
/// The returned value corresponds to the `atime` field of `stat` on Unix
/// platforms and the `ftLastAccessTime` field on Windows platforms.
pub fn from_last_access_time(meta: &fs::Metadata) -> FileTime {
imp::from_last_access_time(meta).emulate_second_only_system()
}
/// Creates a new timestamp from the creation time listed in the specified
/// metadata.
///
/// The returned value corresponds to the `birthtime` field of `stat` on
/// Unix platforms and the `ftCreationTime` field on Windows platforms. Note
/// that not all Unix platforms have this field available and may return
/// `None` in some circumstances.
pub fn from_creation_time(meta: &fs::Metadata) -> Option<FileTime> {
imp::from_creation_time(meta).map(|x| x.emulate_second_only_system())
}
/// Creates a new timestamp from the given SystemTime.
///
/// Windows counts file times since 1601-01-01T00:00:00Z, and cannot
/// represent times before this, but it's possible to create a SystemTime
/// that does. This function will error if passed such a SystemTime.
pub fn from_system_time(time: SystemTime) -> FileTime {
let epoch = if cfg!(windows) {
UNIX_EPOCH - Duration::from_secs(11644473600)
} else {
UNIX_EPOCH
};
time.duration_since(epoch)
.map(|d| FileTime {
seconds: d.as_secs() as i64,
nanos: d.subsec_nanos(),
})
.unwrap_or_else(|e| {
let until_epoch = e.duration();
let (sec_offset, nanos) = if until_epoch.subsec_nanos() == 0 {
(0, 0)
} else {
(-1, 1_000_000_000 - until_epoch.subsec_nanos())
};
FileTime {
seconds: -1 * until_epoch.as_secs() as i64 + sec_offset,
nanos,
}
})
.emulate_second_only_system()
}
/// Returns the whole number of seconds represented by this timestamp.
///
/// Note that this value's meaning is **platform specific**. On Unix
/// platform time stamps are typically relative to January 1, 1970, but on
/// Windows platforms time stamps are relative to January 1, 1601.
pub const fn seconds(&self) -> i64 {
self.seconds
}
/// Returns the whole number of seconds represented by this timestamp,
/// relative to the Unix epoch start of January 1, 1970.
///
/// Note that this does not return the same value as `seconds` for Windows
/// platforms as seconds are relative to a different date there.
pub const fn unix_seconds(&self) -> i64 {
self.seconds - if cfg!(windows) { 11644473600 } else { 0 }
}
/// Returns the nanosecond precision of this timestamp.
///
/// The returned value is always less than one billion and represents a
/// portion of a second forward from the seconds returned by the `seconds`
/// method.
pub const fn nanoseconds(&self) -> u32 {
self.nanos
}
}
impl fmt::Display for FileTime {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}.{:09}s", self.seconds, self.nanos)
}
}
impl From<SystemTime> for FileTime {
fn from(time: SystemTime) -> FileTime {
FileTime::from_system_time(time)
}
}
impl From<FileTime> for SystemTime {
fn from(time: FileTime) -> SystemTime {
let seconds = time.unix_seconds();
if seconds < 0 {
SystemTime::UNIX_EPOCH - Duration::from_secs(-seconds as u64)
+ Duration::from_nanos(time.nanoseconds() as u64)
} else {
SystemTime::UNIX_EPOCH + Duration::new(seconds as u64, time.nanoseconds())
}
}
}
/// Set the last access and modification times for a file on the filesystem.
///
/// This function will set the `atime` and `mtime` metadata fields for a file
/// on the local filesystem, returning any error encountered.
pub fn set_file_times<P>(p: P, atime: FileTime, mtime: FileTime) -> io::Result<()>
where
P: AsRef<Path>,
{
imp::open(p.as_ref())?.set_times(
fs::FileTimes::new()
.set_accessed(atime.into())
.set_modified(mtime.into()),
)
}
/// Set the last access and modification times for a file handle.
///
/// This function will either or both of the `atime` and `mtime` metadata
/// fields for a file handle , returning any error encountered. If `None` is
/// specified then the time won't be updated. If `None` is specified for both
/// options then no action is taken.
pub fn set_file_handle_times(
f: &fs::File,
atime: Option<FileTime>,
mtime: Option<FileTime>,
) -> io::Result<()> {
let mut times = fs::FileTimes::new();
if let Some(t) = atime {
times = times.set_accessed(t.into());
}
if let Some(t) = mtime {
times = times.set_modified(t.into());
}
f.set_times(times)
}
/// Set the last access and modification times for a file on the filesystem.
/// This function does not follow symlink.
///
/// This function will set the `atime` and `mtime` metadata fields for a file
/// on the local filesystem, returning any error encountered.
pub fn set_symlink_file_times<P>(p: P, atime: FileTime, mtime: FileTime) -> io::Result<()>
where
P: AsRef<Path>,
{
imp::set_symlink_file_times(p.as_ref(), atime, mtime)
}
/// Set the last modification time for a file on the filesystem.
///
/// This function will set the `mtime` metadata field for a file on the local
/// filesystem, returning any error encountered.
///
/// # Platform support
///
/// Where supported this will attempt to issue just one syscall to update only
/// the `mtime`, but where not supported this may issue one syscall to learn the
/// existing `atime` so only the `mtime` can be configured.
pub fn set_file_mtime<P>(p: P, mtime: FileTime) -> io::Result<()>
where
P: AsRef<Path>,
{
imp::open(p.as_ref())?.set_times(fs::FileTimes::new().set_modified(mtime.into()))
}
/// Set the last access time for a file on the filesystem.
///
/// This function will set the `atime` metadata field for a file on the local
/// filesystem, returning any error encountered.
///
/// # Platform support
///
/// Where supported this will attempt to issue just one syscall to update only
/// the `atime`, but where not supported this may issue one syscall to learn the
/// existing `mtime` so only the `atime` can be configured.
pub fn set_file_atime<P>(p: P, atime: FileTime) -> io::Result<()>
where
P: AsRef<Path>,
{
imp::open(p.as_ref())?.set_times(fs::FileTimes::new().set_accessed(atime.into()))
}
#[cfg(test)]
mod tests {
use super::{
set_file_atime, set_file_handle_times, set_file_mtime, set_file_times,
set_symlink_file_times, FileTime,
};
use std::fs::{self, File};
use std::io;
use std::path::Path;
use std::time::{Duration, UNIX_EPOCH};
use tempfile::Builder;
#[cfg(unix)]
fn make_symlink_file<P, Q>(src: P, dst: Q) -> io::Result<()>
where
P: AsRef<Path>,
Q: AsRef<Path>,
{
use std::os::unix::fs::symlink;
symlink(src, dst)
}
#[cfg(windows)]
fn make_symlink_file<P, Q>(src: P, dst: Q) -> io::Result<()>
where
P: AsRef<Path>,
Q: AsRef<Path>,
{
use std::os::windows::fs::symlink_file;
symlink_file(src, dst)
}
#[cfg(unix)]
fn make_symlink_dir<P, Q>(src: P, dst: Q) -> io::Result<()>
where
P: AsRef<Path>,
Q: AsRef<Path>,
{
use std::os::unix::fs::symlink;
symlink(src, dst)
}
#[cfg(windows)]
fn make_symlink_dir<P, Q>(src: P, dst: Q) -> io::Result<()>
where
P: AsRef<Path>,
Q: AsRef<Path>,
{
use std::os::windows::fs::symlink_dir;
symlink_dir(src, dst)
}
#[test]
#[cfg(windows)]
fn from_unix_time_test() {
let time = FileTime::from_unix_time(10, 100_000_000);
assert_eq!(11644473610, time.seconds);
assert_eq!(100_000_000, time.nanos);
let time = FileTime::from_unix_time(-10, 100_000_000);
assert_eq!(11644473590, time.seconds);
assert_eq!(100_000_000, time.nanos);
let time = FileTime::from_unix_time(-12_000_000_000, 0);
assert_eq!(-355526400, time.seconds);
assert_eq!(0, time.nanos);
}
#[test]
#[cfg(not(windows))]
fn from_unix_time_test() {
let time = FileTime::from_unix_time(10, 100_000_000);
assert_eq!(10, time.seconds);
assert_eq!(100_000_000, time.nanos);
let time = FileTime::from_unix_time(-10, 100_000_000);
assert_eq!(-10, time.seconds);
assert_eq!(100_000_000, time.nanos);
let time = FileTime::from_unix_time(-12_000_000_000, 0);
assert_eq!(-12_000_000_000, time.seconds);
assert_eq!(0, time.nanos);
}
#[test]
#[cfg(windows)]
fn to_from_system_time_test() {
let systime = UNIX_EPOCH + Duration::from_secs(10);
let time = FileTime::from_system_time(systime);
assert_eq!(11644473610, time.seconds);
assert_eq!(0, time.nanos);
assert_eq!(systime, time.into());
let systime = UNIX_EPOCH - Duration::from_secs(10);
let time = FileTime::from_system_time(systime);
assert_eq!(11644473590, time.seconds);
assert_eq!(0, time.nanos);
assert_eq!(systime, time.into());
let systime = UNIX_EPOCH - Duration::from_millis(1100);
let time = FileTime::from_system_time(systime);
assert_eq!(11644473598, time.seconds);
assert_eq!(900_000_000, time.nanos);
assert_eq!(systime, time.into());
}
#[test]
#[cfg(not(windows))]
fn to_from_system_time_test() {
let systime = UNIX_EPOCH + Duration::from_secs(10);
let time = FileTime::from_system_time(systime);
assert_eq!(10, time.seconds);
assert_eq!(0, time.nanos);
assert_eq!(systime, time.into());
let systime = UNIX_EPOCH - Duration::from_secs(10);
let time = FileTime::from_system_time(systime);
assert_eq!(-10, time.seconds);
assert_eq!(0, time.nanos);
assert_eq!(systime, time.into());
let systime = UNIX_EPOCH - Duration::from_millis(1100);
let time = FileTime::from_system_time(systime);
assert_eq!(-2, time.seconds);
assert_eq!(900_000_000, time.nanos);
assert_eq!(systime, time.into());
let systime = UNIX_EPOCH - Duration::from_secs(12_000_000);
let time = FileTime::from_system_time(systime);
assert_eq!(-12_000_000, time.seconds);
assert_eq!(0, time.nanos);
assert_eq!(systime, time.into());
}
#[test]
fn set_file_times_test() -> io::Result<()> {
let td = Builder::new().prefix("filetime").tempdir()?;
let path = td.path().join("foo.txt");
let mut f = File::create(&path)?;
let metadata = fs::metadata(&path)?;
let mtime = FileTime::from_last_modification_time(&metadata);
let atime = FileTime::from_last_access_time(&metadata);
set_file_times(&path, atime, mtime)?;
let new_mtime = FileTime::from_unix_time(10_000, 0);
set_file_times(&path, atime, new_mtime)?;
let metadata = fs::metadata(&path)?;
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime, "modification should be updated");
// Update just mtime
let new_mtime = FileTime::from_unix_time(20_000, 0);
set_file_handle_times(&mut f, None, Some(new_mtime))?;
let metadata = f.metadata()?;
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime, "modification time should be updated");
let new_atime = FileTime::from_last_access_time(&metadata);
assert_eq!(atime, new_atime, "accessed time should not be updated");
// Update just atime
let new_atime = FileTime::from_unix_time(30_000, 0);
set_file_handle_times(&mut f, Some(new_atime), None)?;
let metadata = f.metadata()?;
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime, "modification time should not be updated");
let atime = FileTime::from_last_access_time(&metadata);
assert_eq!(atime, new_atime, "accessed time should be updated");
let spath = td.path().join("bar.txt");
make_symlink_file(&path, &spath)?;
let metadata = fs::symlink_metadata(&spath)?;
let smtime = FileTime::from_last_modification_time(&metadata);
set_file_times(&spath, atime, mtime)?;
let metadata = fs::metadata(&path)?;
let cur_mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, cur_mtime);
let metadata = fs::symlink_metadata(&spath)?;
let cur_mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(smtime, cur_mtime);
set_file_times(&spath, atime, new_mtime)?;
let metadata = fs::metadata(&path)?;
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime);
let metadata = fs::symlink_metadata(&spath)?;
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, smtime);
Ok(())
}
#[test]
fn set_dir_times_test() -> io::Result<()> {
let td = Builder::new().prefix("filetime").tempdir()?;
let path = td.path().join("foo");
fs::create_dir(&path)?;
let metadata = fs::metadata(&path)?;
let mtime = FileTime::from_last_modification_time(&metadata);
let atime = FileTime::from_last_access_time(&metadata);
set_file_times(&path, atime, mtime)?;
let new_mtime = FileTime::from_unix_time(10_000, 0);
set_file_times(&path, atime, new_mtime)?;
let metadata = fs::metadata(&path)?;
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime, "modification should be updated");
// Update just mtime
let new_mtime = FileTime::from_unix_time(20_000, 0);
set_file_mtime(&path, new_mtime)?;
let metadata = fs::metadata(&path)?;
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime, "modification time should be updated");
let new_atime = FileTime::from_last_access_time(&metadata);
assert_eq!(atime, new_atime, "accessed time should not be updated");
// Update just atime
let new_atime = FileTime::from_unix_time(30_000, 0);
set_file_atime(&path, new_atime)?;
let metadata = fs::metadata(&path)?;
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime, "modification time should not be updated");
let atime = FileTime::from_last_access_time(&metadata);
assert_eq!(atime, new_atime, "accessed time should be updated");
let spath = td.path().join("bar");
make_symlink_dir(&path, &spath)?;
let metadata = fs::symlink_metadata(&spath)?;
let smtime = FileTime::from_last_modification_time(&metadata);
set_file_times(&spath, atime, mtime)?;
let metadata = fs::metadata(&path)?;
let cur_mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, cur_mtime);
let metadata = fs::symlink_metadata(&spath)?;
let cur_mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(smtime, cur_mtime);
set_file_times(&spath, atime, new_mtime)?;
let metadata = fs::metadata(&path)?;
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime);
let metadata = fs::symlink_metadata(&spath)?;
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, smtime);
Ok(())
}
#[test]
fn set_file_times_pre_unix_epoch_test() {
let td = Builder::new().prefix("filetime").tempdir().unwrap();
let path = td.path().join("foo.txt");
File::create(&path).unwrap();
let metadata = fs::metadata(&path).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
let atime = FileTime::from_last_access_time(&metadata);
set_file_times(&path, atime, mtime).unwrap();
let new_mtime = FileTime::from_unix_time(-10_000, 0);
if cfg!(target_os = "aix") {
// On AIX, os checks if the unix timestamp is valid.
let result = set_file_times(&path, atime, new_mtime);
assert!(result.is_err());
assert!(result.err().unwrap().kind() == std::io::ErrorKind::InvalidInput);
} else {
set_file_times(&path, atime, new_mtime).unwrap();
let metadata = fs::metadata(&path).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime);
}
}
#[test]
#[cfg(windows)]
fn set_file_times_pre_windows_epoch_test() {
let td = Builder::new().prefix("filetime").tempdir().unwrap();
let path = td.path().join("foo.txt");
File::create(&path).unwrap();
let metadata = fs::metadata(&path).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
let atime = FileTime::from_last_access_time(&metadata);
set_file_times(&path, atime, mtime).unwrap();
}
#[test]
fn set_symlink_file_times_test() {
let td = Builder::new().prefix("filetime").tempdir().unwrap();
let path = td.path().join("foo.txt");
File::create(&path).unwrap();
let metadata = fs::metadata(&path).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
let atime = FileTime::from_last_access_time(&metadata);
set_symlink_file_times(&path, atime, mtime).unwrap();
let new_mtime = FileTime::from_unix_time(10_000, 0);
set_symlink_file_times(&path, atime, new_mtime).unwrap();
let metadata = fs::metadata(&path).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime);
let spath = td.path().join("bar.txt");
make_symlink_file(&path, &spath).unwrap();
let metadata = fs::symlink_metadata(&spath).unwrap();
let smtime = FileTime::from_last_modification_time(&metadata);
let satime = FileTime::from_last_access_time(&metadata);
set_symlink_file_times(&spath, smtime, satime).unwrap();
let metadata = fs::metadata(&path).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime);
let new_smtime = FileTime::from_unix_time(20_000, 0);
set_symlink_file_times(&spath, atime, new_smtime).unwrap();
let metadata = fs::metadata(&spath).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime);
let metadata = fs::symlink_metadata(&spath).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_smtime);
}
#[test]
fn set_symlink_dir_times_test() {
let td = Builder::new().prefix("filetime").tempdir().unwrap();
let path = td.path().join("foo");
fs::create_dir(&path).unwrap();
let metadata = fs::metadata(&path).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
let atime = FileTime::from_last_access_time(&metadata);
set_symlink_file_times(&path, atime, mtime).unwrap();
let new_mtime = FileTime::from_unix_time(10_000, 0);
set_symlink_file_times(&path, atime, new_mtime).unwrap();
let metadata = fs::metadata(&path).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime);
let spath = td.path().join("bar");
make_symlink_dir(&path, &spath).unwrap();
let metadata = fs::symlink_metadata(&spath).unwrap();
let smtime = FileTime::from_last_modification_time(&metadata);
let satime = FileTime::from_last_access_time(&metadata);
set_symlink_file_times(&spath, smtime, satime).unwrap();
let metadata = fs::metadata(&path).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime);
let new_smtime = FileTime::from_unix_time(20_000, 0);
set_symlink_file_times(&spath, atime, new_smtime).unwrap();
let metadata = fs::metadata(&spath).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime);
let metadata = fs::symlink_metadata(&spath).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_smtime);
}
#[test]
fn set_single_time_test() {
use super::{set_file_atime, set_file_mtime};
let td = Builder::new().prefix("filetime").tempdir().unwrap();
let path = td.path().join("foo.txt");
File::create(&path).unwrap();
let metadata = fs::metadata(&path).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
let atime = FileTime::from_last_access_time(&metadata);
set_file_times(&path, atime, mtime).unwrap();
let new_mtime = FileTime::from_unix_time(10_000, 0);
set_file_mtime(&path, new_mtime).unwrap();
let metadata = fs::metadata(&path).unwrap();
let mtime = FileTime::from_last_modification_time(&metadata);
assert_eq!(mtime, new_mtime, "modification time should be updated");
assert_eq!(
atime,
FileTime::from_last_access_time(&metadata),
"access time should not be updated",
);
let new_atime = FileTime::from_unix_time(20_000, 0);
set_file_atime(&path, new_atime).unwrap();
let metadata = fs::metadata(&path).unwrap();
let atime = FileTime::from_last_access_time(&metadata);
assert_eq!(atime, new_atime, "access time should be updated");
assert_eq!(
mtime,
FileTime::from_last_modification_time(&metadata),
"modification time should not be updated"
);
}
}
+37
View File
@@ -0,0 +1,37 @@
use crate::FileTime;
use std::fs::{self};
use std::io;
use std::os::unix::prelude::*;
use std::path::Path;
pub const O_NOFOLLOW: i32 = 0x20000;
pub fn set_symlink_file_times(p: &Path, atime: FileTime, mtime: FileTime) -> io::Result<()> {
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(O_NOFOLLOW)
.open(p)?;
crate::set_file_handle_times(&file, Some(atime), Some(mtime))
}
pub fn from_last_modification_time(meta: &fs::Metadata) -> FileTime {
FileTime {
seconds: meta.mtime(),
nanos: meta.mtime_nsec() as u32,
}
}
pub fn from_last_access_time(meta: &fs::Metadata) -> FileTime {
FileTime {
seconds: meta.atime(),
nanos: meta.atime_nsec() as u32,
}
}
pub fn from_creation_time(_meta: &fs::Metadata) -> Option<FileTime> {
None
}
pub fn open(path: &Path) -> io::Result<fs::File> {
fs::File::open(path)
}
+31
View File
@@ -0,0 +1,31 @@
use crate::FileTime;
use std::ffi::CString;
use std::io;
use std::os::unix::prelude::*;
use std::path::Path;
pub fn set_symlink_file_times(p: &Path, atime: FileTime, mtime: FileTime) -> io::Result<()> {
set_times(p, Some(atime), Some(mtime), true)
}
fn set_times(
p: &Path,
atime: Option<FileTime>,
mtime: Option<FileTime>,
symlink: bool,
) -> io::Result<()> {
let flags = if symlink {
libc::AT_SYMLINK_NOFOLLOW
} else {
0
};
let p = CString::new(p.as_os_str().as_bytes())?;
let times = [super::to_timespec(&atime), super::to_timespec(&mtime)];
let rc = unsafe { libc::utimensat(libc::AT_FDCWD, p.as_ptr(), times.as_ptr(), flags) };
if rc == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
+47
View File
@@ -0,0 +1,47 @@
//! On Linux we try to use the more accurate `utimensat` syscall but this isn't
//! always available so we also fall back to `utimes` if we couldn't find
//! `utimensat` at runtime.
use crate::FileTime;
use std::ffi::CString;
use std::io;
use std::os::unix::prelude::*;
use std::path::Path;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering::SeqCst;
pub fn set_symlink_file_times(p: &Path, atime: FileTime, mtime: FileTime) -> io::Result<()> {
set_times(p, Some(atime), Some(mtime), true)
}
fn set_times(
p: &Path,
atime: Option<FileTime>,
mtime: Option<FileTime>,
symlink: bool,
) -> io::Result<()> {
let flags = if symlink {
libc::AT_SYMLINK_NOFOLLOW
} else {
0
};
// Same as the `if` statement above.
static INVALID: AtomicBool = AtomicBool::new(false);
if !INVALID.load(SeqCst) {
let p = CString::new(p.as_os_str().as_bytes())?;
let times = [super::to_timespec(&atime), super::to_timespec(&mtime)];
let rc = unsafe { libc::utimensat(libc::AT_FDCWD, p.as_ptr(), times.as_ptr(), flags) };
if rc == 0 {
return Ok(());
}
let err = io::Error::last_os_error();
if err.raw_os_error() == Some(libc::ENOSYS) {
INVALID.store(true, SeqCst);
} else {
return Err(err);
}
}
super::utimes::set_times(p, atime, mtime, symlink)
}
+68
View File
@@ -0,0 +1,68 @@
//! Beginning with macOS 10.13, `utimensat` is supported by the OS, so here, we check if the symbol exists
//! and if not, we fallback to `utimes`.
use crate::FileTime;
use libc::{c_char, c_int, timespec};
use std::ffi::{CStr, CString};
use std::os::unix::prelude::*;
use std::path::Path;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering::SeqCst;
use std::{io, mem};
pub fn set_symlink_file_times(p: &Path, atime: FileTime, mtime: FileTime) -> io::Result<()> {
set_times(p, Some(atime), Some(mtime), true)
}
fn set_times(
p: &Path,
atime: Option<FileTime>,
mtime: Option<FileTime>,
symlink: bool,
) -> io::Result<()> {
// Attempt to use the `utimensat` syscall, but if it's not supported by the
// current kernel then fall back to an older syscall.
if let Some(func) = utimensat() {
let flags = if symlink {
libc::AT_SYMLINK_NOFOLLOW
} else {
0
};
let p = CString::new(p.as_os_str().as_bytes())?;
let times = [super::to_timespec(&atime), super::to_timespec(&mtime)];
let rc = unsafe { func(libc::AT_FDCWD, p.as_ptr(), times.as_ptr(), flags) };
if rc == 0 {
return Ok(());
} else {
return Err(io::Error::last_os_error());
}
}
super::utimes::set_times(p, atime, mtime, symlink)
}
fn utimensat() -> Option<unsafe extern "C" fn(c_int, *const c_char, *const timespec, c_int) -> c_int>
{
static ADDR: AtomicUsize = AtomicUsize::new(0);
unsafe {
fetch(&ADDR, CStr::from_bytes_with_nul_unchecked(b"utimensat\0"))
.map(|sym| mem::transmute(sym))
}
}
fn fetch(cache: &AtomicUsize, name: &CStr) -> Option<usize> {
match cache.load(SeqCst) {
0 => {}
1 => return None,
n => return Some(n),
}
let sym = unsafe { libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr() as *const _) };
let (val, ret) = if sym.is_null() {
(1, None)
} else {
(sym as usize, Some(sym as usize))
};
cache.store(val, SeqCst);
return ret;
}
+73
View File
@@ -0,0 +1,73 @@
use crate::FileTime;
use libc::{time_t, timespec, UTIME_OMIT};
use std::fs;
use std::io;
use std::os::unix::prelude::*;
use std::path::Path;
cfg_if::cfg_if! {
if #[cfg(target_os = "linux")] {
mod utimes;
mod linux;
pub use self::linux::*;
} else if #[cfg(target_os = "android")] {
mod android;
pub use self::android::*;
} else if #[cfg(target_os = "macos")] {
mod utimes;
mod macos;
pub use self::macos::*;
} else if #[cfg(any(target_os = "aix",
target_os = "nto",
target_os = "solaris",
target_os = "illumos",
target_os = "emscripten",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "haiku"))] {
mod utimensat;
pub use self::utimensat::*;
} else {
mod utimes;
pub use self::utimes::*;
}
}
#[allow(dead_code)]
fn to_timespec(ft: &Option<FileTime>) -> timespec {
let mut ts: timespec = unsafe { std::mem::zeroed() };
if let &Some(ft) = ft {
ts.tv_sec = ft.seconds() as time_t;
ts.tv_nsec = ft.nanoseconds() as _;
} else {
ts.tv_sec = 0;
ts.tv_nsec = UTIME_OMIT as _;
}
ts
}
pub fn from_last_modification_time(meta: &fs::Metadata) -> FileTime {
FileTime {
seconds: meta.mtime(),
nanos: meta.mtime_nsec() as u32,
}
}
pub fn from_last_access_time(meta: &fs::Metadata) -> FileTime {
FileTime {
seconds: meta.atime(),
nanos: meta.atime_nsec() as u32,
}
}
pub fn from_creation_time(meta: &fs::Metadata) -> Option<FileTime> {
meta.created().map(|i| i.into()).ok()
}
pub fn open(path: &Path) -> io::Result<fs::File> {
// Try a read-only open, but if that fails try a write-only open, and if
// that fails then give up.
fs::File::open(path).or_else(|_| fs::OpenOptions::new().write(true).open(path))
}
+37
View File
@@ -0,0 +1,37 @@
use crate::FileTime;
use std::ffi::CString;
use std::io;
use std::os::unix::prelude::*;
use std::path::Path;
pub fn set_symlink_file_times(p: &Path, atime: FileTime, mtime: FileTime) -> io::Result<()> {
set_times(p, Some(atime), Some(mtime), true)
}
fn set_times(
p: &Path,
atime: Option<FileTime>,
mtime: Option<FileTime>,
symlink: bool,
) -> io::Result<()> {
let flags = if symlink {
if cfg!(target_os = "emscripten") {
return Err(io::Error::new(
io::ErrorKind::Other,
"emscripten does not support utimensat for symlinks",
));
}
libc::AT_SYMLINK_NOFOLLOW
} else {
0
};
let p = CString::new(p.as_os_str().as_bytes())?;
let times = [super::to_timespec(&atime), super::to_timespec(&mtime)];
let rc = unsafe { libc::utimensat(libc::AT_FDCWD, p.as_ptr(), times.as_ptr(), flags) };
if rc == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
}
+75
View File
@@ -0,0 +1,75 @@
use crate::FileTime;
use std::ffi::CString;
use std::fs;
use std::io;
use std::os::unix::prelude::*;
use std::path::Path;
fn get_times(
atime: Option<FileTime>,
mtime: Option<FileTime>,
current: impl FnOnce() -> io::Result<fs::Metadata>,
) -> io::Result<Option<(FileTime, FileTime)>> {
let pair = match (atime, mtime) {
(Some(a), Some(b)) => (a, b),
(None, None) => return Ok(None),
(Some(a), None) => {
let meta = current()?;
(a, FileTime::from_last_modification_time(&meta))
}
(None, Some(b)) => {
let meta = current()?;
(FileTime::from_last_access_time(&meta), b)
}
};
Ok(Some(pair))
}
#[allow(dead_code)]
pub fn set_symlink_file_times(p: &Path, atime: FileTime, mtime: FileTime) -> io::Result<()> {
set_times(p, Some(atime), Some(mtime), true)
}
pub fn set_times(
p: &Path,
atime: Option<FileTime>,
mtime: Option<FileTime>,
symlink: bool,
) -> io::Result<()> {
let (atime, mtime) = match get_times(atime, mtime, || p.metadata())? {
Some(pair) => pair,
None => return Ok(()),
};
let p = CString::new(p.as_os_str().as_bytes())?;
let times = [to_timeval(&atime), to_timeval(&mtime)];
let rc = unsafe {
if symlink {
libc::lutimes(p.as_ptr(), times.as_ptr())
} else {
libc::utimes(p.as_ptr(), times.as_ptr())
}
};
return if rc == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
};
}
fn to_timeval(ft: &FileTime) -> libc::timeval {
libc::timeval {
tv_sec: ft.seconds() as libc::time_t,
tv_usec: (ft.nanoseconds() / 1000) as libc::suseconds_t,
}
}
#[cfg(target_env = "uclibc")]
fn to_timespec(ft: &FileTime) -> libc::timespec {
libc::timespec {
tv_sec: ft.seconds() as libc::time_t,
#[cfg(all(target_arch = "x86_64", target_pointer_width = "32"))]
tv_nsec: (ft.nanoseconds()) as i64,
#[cfg(not(all(target_arch = "x86_64", target_pointer_width = "32")))]
tv_nsec: (ft.nanoseconds()) as libc::c_long,
}
}
+21
View File
@@ -0,0 +1,21 @@
// WASI exposes timestamps through std metadata, but has no Unix MetadataExt.
use crate::FileTime;
use std::{fs, io, path::Path};
pub fn from_last_modification_time(meta: &fs::Metadata) -> FileTime {
meta.modified().map(FileTime::from_system_time).unwrap_or_else(|_| FileTime::zero())
}
pub fn from_last_access_time(meta: &fs::Metadata) -> FileTime {
meta.accessed().map(FileTime::from_system_time).unwrap_or_else(|_| FileTime::zero())
}
pub fn from_creation_time(meta: &fs::Metadata) -> Option<FileTime> {
meta.created().ok().map(FileTime::from_system_time)
}
pub fn set_symlink_file_times(_p: &Path, _atime: FileTime, _mtime: FileTime) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "WASI symlink timestamp mutation is not implemented"))
}
pub fn open(path: &Path) -> io::Result<fs::File> { fs::File::open(path) }
+24
View File
@@ -0,0 +1,24 @@
use crate::FileTime;
use std::fs;
use std::io;
use std::path::Path;
pub fn set_symlink_file_times(_p: &Path, _atime: FileTime, _mtime: FileTime) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Other, "Wasm not implemented"))
}
pub fn from_last_modification_time(_meta: &fs::Metadata) -> FileTime {
unimplemented!()
}
pub fn from_last_access_time(_meta: &fs::Metadata) -> FileTime {
unimplemented!()
}
pub fn from_creation_time(_meta: &fs::Metadata) -> Option<FileTime> {
unimplemented!()
}
pub fn open(path: &Path) -> io::Result<fs::File> {
fs::File::open(path)
}
+45
View File
@@ -0,0 +1,45 @@
use crate::FileTime;
use std::fs::{self, OpenOptions};
use std::io;
use std::os::windows::fs::OpenOptionsExt;
use std::os::windows::prelude::*;
use std::path::Path;
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x2000000;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x200000;
pub fn open(p: &Path) -> io::Result<fs::File> {
OpenOptions::new()
.write(true)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
.open(p)
}
pub fn set_symlink_file_times(p: &Path, atime: FileTime, mtime: FileTime) -> io::Result<()> {
let f = OpenOptions::new()
.write(true)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS)
.open(p)?;
crate::set_file_handle_times(&f, Some(atime), Some(mtime))
}
pub fn from_last_modification_time(meta: &fs::Metadata) -> FileTime {
from_intervals(meta.last_write_time())
}
pub fn from_last_access_time(meta: &fs::Metadata) -> FileTime {
from_intervals(meta.last_access_time())
}
pub fn from_creation_time(meta: &fs::Metadata) -> Option<FileTime> {
Some(from_intervals(meta.creation_time()))
}
fn from_intervals(ticks: u64) -> FileTime {
// Windows write times are in 100ns intervals, so do a little math to
// get it into the right representation.
FileTime {
seconds: (ticks / (1_000_000_000 / 100)) as i64,
nanos: ((ticks % (1_000_000_000 / 100)) * 100) as u32,
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "dd39fb66e3b1551c9fe849043e39b357a9799ca1"
},
"path_in_vcs": "gix-index"
}
+1533
View File
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
rust-version = "1.88"
name = "gix-index"
version = "0.58.0"
authors = ["Sebastian Thiel <sebastian.thiel@icloud.com>"]
build = false
include = [
"/src/**/*",
"/LICENSE-*",
"/README.md",
]
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "A work-in-progress crate of the gitoxide project dedicated implementing the git index file"
readme = "README.md"
license = "MIT OR Apache-2.0"
repository = "https://github.com/GitoxideLabs/gitoxide"
resolver = "2"
[package.metadata.docs.rs]
features = [
"sha1",
"document-features",
"serde",
]
[features]
serde = [
"dep:serde",
"smallvec/serde",
"gix-hash/serde",
]
sha1 = ["gix-hash/sha1"]
sha256 = ["gix-hash/sha256"]
[lib]
name = "gix_index"
path = "src/lib.rs"
test = true
doctest = false
[dependencies.bitflags]
version = "2"
[dependencies.bstr]
version = "1.12.0"
default-features = false
[dependencies.document-features]
version = "0.2.0"
optional = true
[dependencies.filetime]
version = "0.2.29"
[dependencies.fnv]
version = "1.0.7"
[dependencies.gix-bitmap]
version = "^0.6.1"
[dependencies.gix-error]
version = "^0.5.2"
[dependencies.gix-fs]
version = "^0.24.1"
[dependencies.gix-hash]
version = "^0.28.1"
[dependencies.gix-lock]
version = "^26.0.0"
[dependencies.gix-object]
version = "^0.66.1"
[dependencies.gix-parallel]
version = "^0.50.0"
[dependencies.gix-trace]
version = "^0.3.1"
[dependencies.gix-traverse]
version = "^0.64.0"
[dependencies.gix-utils]
version = "^0.5.1"
features = ["progress"]
[dependencies.gix-validate]
version = "^0.13.1"
[dependencies.hashbrown]
version = "0.17.1"
[dependencies.itoa]
version = "1.0.17"
[dependencies.memmap2]
version = "0.9.11"
[dependencies.serde]
version = "1.0.114"
features = ["derive"]
optional = true
default-features = false
[dependencies.smallvec]
version = "1.15.1"
[dev-dependencies.criterion]
version = "0.8.2"
[dev-dependencies.filetime]
version = "0.2.29"
[dev-dependencies.insta]
version = "1.46.3"
features = ["filters"]
[target."cfg(unix)".dependencies.libc]
version = "0.2.186"
[target."cfg(unix)".dependencies.rustix]
version = "1.1.2"
features = [
"std",
"fs",
]
default-features = false
[lints.clippy]
assert_is_empty = "allow"
bool_to_int_with_if = "allow"
borrow_as_ptr = "allow"
cast_lossless = "allow"
cast_possible_truncation = "allow"
cast_possible_wrap = "allow"
cast_precision_loss = "allow"
cast_sign_loss = "allow"
checked_conversions = "allow"
copy_iterator = "allow"
default_trait_access = "allow"
doc_markdown = "allow"
empty_docs = "allow"
enum_glob_use = "allow"
explicit_deref_methods = "allow"
explicit_into_iter_loop = "allow"
explicit_iter_loop = "allow"
filter_map_next = "allow"
fn_params_excessive_bools = "allow"
if_not_else = "allow"
ignored_unit_patterns = "allow"
implicit_clone = "allow"
inconsistent_struct_constructor = "allow"
inefficient_to_string = "allow"
inline_always = "allow"
items_after_statements = "allow"
iter_not_returning_iterator = "allow"
iter_without_into_iter = "allow"
large_enum_variant = "allow"
large_stack_arrays = "allow"
manual_assert = "allow"
manual_is_variant_and = "allow"
manual_let_else = "allow"
manual_string_new = "allow"
many_single_char_names = "allow"
match_bool = "allow"
match_same_arms = "allow"
match_wild_err_arm = "allow"
match_wildcard_for_single_variants = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
module_name_repetitions = "allow"
must_use_candidate = "allow"
mut_mut = "allow"
naive_bytecount = "allow"
needless_continue = "allow"
needless_for_each = "allow"
needless_pass_by_value = "allow"
needless_raw_string_hashes = "allow"
no_effect_underscore_binding = "allow"
option_option = "allow"
range_plus_one = "allow"
redundant_else = "allow"
result_large_err = "allow"
return_self_not_must_use = "allow"
should_panic_without_expect = "allow"
similar_names = "allow"
single_match_else = "allow"
stable_sort_primitive = "allow"
struct_excessive_bools = "allow"
struct_field_names = "allow"
too_long_first_doc_paragraph = "allow"
too_many_lines = "allow"
transmute_ptr_to_ptr = "allow"
trivially_copy_pass_by_ref = "allow"
unnecessary_join = "allow"
unnecessary_wraps = "allow"
unreadable_literal = "allow"
unused_self = "allow"
used_underscore_binding = "allow"
wildcard_imports = "allow"
[lints.clippy.pedantic]
level = "warn"
priority = -1
[lints.rust]
+78
View File
@@ -0,0 +1,78 @@
lints.workspace = true
[package]
name = "gix-index"
version = "0.58.0"
repository = "https://github.com/GitoxideLabs/gitoxide"
license = "MIT OR Apache-2.0"
description = "A work-in-progress crate of the gitoxide project dedicated implementing the git index file"
authors = ["Sebastian Thiel <sebastian.thiel@icloud.com>"]
edition = "2024"
include = ["/src/**/*", "/LICENSE-*", "/README.md"]
rust-version = "1.88"
[lib]
doctest = false
test = true
[[bench]]
name = "from-tree"
harness = false
path = "./benches/from_tree.rs"
[features]
## Enable support for the SHA-1 hash by enabling the respective feature in the `gix-hash` crate.
sha1 = ["gix-hash/sha1"]
## Enable support for the SHA-256 hash by enabling the respective feature in the `gix-hash` crate.
sha256 = ["gix-hash/sha256"]
## Data structures implement `serde::Serialize` and `serde::Deserialize`.
serde = ["dep:serde", "smallvec/serde", "gix-hash/serde"]
[dependencies]
gix-trace = { version = "^0.3.1", path = "../gix-trace" }
gix-parallel = { version = "^0.50.0", path = "../gix-parallel" }
gix-hash = { version = "^0.28.1", path = "../gix-hash" }
gix-error = { version = "^0.5.2", path = "../gix-error" }
gix-bitmap = { version = "^0.6.1", path = "../gix-bitmap" }
gix-object = { version = "^0.66.1", path = "../gix-object" }
gix-validate = { version = "^0.13.1", path = "../gix-validate" }
gix-traverse = { version = "^0.64.0", path = "../gix-traverse" }
gix-lock = { version = "^26.0.0", path = "../gix-lock" }
gix-fs = { version = "^0.24.1", path = "../gix-fs" }
gix-utils = { version = "^0.5.1", path = "../gix-utils", features = ["progress"] }
hashbrown = "0.17.1"
fnv = "1.0.7"
memmap2 = "0.9.11"
filetime = "0.2.29"
bstr = { version = "1.12.0", default-features = false }
serde = { version = "1.0.114", optional = true, default-features = false, features = [
"derive",
] }
smallvec = "1.15.1"
itoa = "1.0.17"
bitflags = "2"
document-features = { version = "0.2.0", optional = true }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1.2", default-features = false, features = [
"std",
"fs",
] }
libc = { version = "0.2.186" }
[dev-dependencies]
criterion = "0.8.2"
gix-testtools = { path = "../tests/tools", default-features = false, features = ["sha1", "sha256"] }
gix-hash = { path = "../gix-hash", features = ["sha1", "sha256"] }
gix-hashtable = { path = "../gix-hashtable" }
gix-odb = { path = "../gix-odb" }
gix-object = { path = "../gix-object" }
filetime = "0.2.29"
insta = { version = "1.46.3", features = ["filters"] }
[package.metadata.docs.rs]
features = ["sha1", "document-features", "serde"]
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+17
View File
@@ -0,0 +1,17 @@
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11
View File
@@ -0,0 +1,11 @@
#### Test fixtures
Most of the test indices are snatched directly from the unit test suite of `git` itself, usually by running something like the following
```shell
./t1700-split-index.sh -r 2 --debug
```
Then one finds all test state and the index in particular in `trash directory/t1700-split-index/.git/index` and can possibly copy it over and use as fixture.
The preferred way is to find a test of interest, and use its setup code within one of our own fixture scripts that are executed once to generate the file of interest.
+708
View File
@@ -0,0 +1,708 @@
use std::{cmp::Ordering, ops::Range};
use bstr::{BStr, ByteSlice, ByteVec};
use filetime::FileTime;
use crate::{
AccelerateLookup, Entry, PathStorage, PathStorageRef, State, Version, entry,
entry::{Stage, StageRaw},
extension,
};
// TODO: integrate this somehow, somewhere, depending on later usage.
#[expect(dead_code, reason = "to be used for when we handle checkouts/resets better")]
mod sparse;
/// General information and entries
impl State {
/// Return the version used to store this state's information on disk.
pub fn version(&self) -> Version {
self.version
}
/// Returns time at which the state was created, indicating its freshness compared to other files on disk.
pub fn timestamp(&self) -> FileTime {
self.timestamp
}
/// Updates the timestamp of this state, indicating its freshness compared to other files on disk.
///
/// Be careful about using this as setting a timestamp without correctly updating the index
/// **will cause (file system) race conditions** see racy-git.txt in the git documentation
/// for more details.
pub fn set_timestamp(&mut self, timestamp: FileTime) {
self.timestamp = timestamp;
}
/// Return the kind of hashes used in this instance.
pub fn object_hash(&self) -> gix_hash::Kind {
self.object_hash
}
/// Return our entries
pub fn entries(&self) -> &[Entry] {
&self.entries
}
/// Return our path backing, the place which keeps all paths one after another, with entries storing only the range to access them.
pub fn path_backing(&self) -> &PathStorageRef {
&self.path_backing
}
/// Runs `filter_map` on all entries, returning an iterator over all paths along with the result of `filter_map`.
pub fn entries_with_paths_by_filter_map<'a, T>(
&'a self,
mut filter_map: impl FnMut(&'a BStr, &Entry) -> Option<T> + 'a,
) -> impl Iterator<Item = (&'a BStr, T)> + 'a {
self.entries.iter().filter_map(move |e| {
let p = e.path(self);
filter_map(p, e).map(|t| (p, t))
})
}
/// Return mutable entries along with their path, as obtained from `backing`.
pub fn entries_mut_with_paths_in<'state, 'backing>(
&'state mut self,
backing: &'backing PathStorageRef,
) -> impl Iterator<Item = (&'state mut Entry, &'backing BStr)> {
self.entries.iter_mut().map(move |e| {
let path = backing[e.path.clone()].as_bstr();
(e, path)
})
}
/// Find the entry index in [`entries()`][State::entries()] matching the given `path` and `stage`, or `None`.
///
/// The `path` must use the repository-relative, slash-separated [`State`] path format.
///
/// Use the index for accessing multiple stages if they exists, but at least the single matching entry.
pub fn entry_index_by_path_and_stage(&self, path: &BStr, stage: entry::Stage) -> Option<usize> {
let mut stage_cmp = Ordering::Equal;
let idx = self
.entries
.binary_search_by(|e| {
let res = e.path(self).cmp(path);
if res.is_eq() {
stage_cmp = e.stage().cmp(&stage);
}
res
})
.ok()?;
self.entry_index_by_idx_and_stage(path, idx, stage as StageRaw, stage_cmp)
}
/// Walk as far in `direction` as possible, with [`Ordering::Greater`] towards higher stages, and [`Ordering::Less`]
/// towards lower stages, and return the lowest or highest seen stage.
/// Return `None` if there is no greater or smaller stage.
fn walk_entry_stages(&self, path: &BStr, base: usize, direction: Ordering) -> Option<usize> {
match direction {
Ordering::Greater => self
.entries
.get(base + 1..)?
.iter()
.enumerate()
.take_while(|(_, e)| e.path(self) == path)
.last()
.map(|(idx, _)| base + 1 + idx),
Ordering::Equal => Some(base),
Ordering::Less => self.entries[..base]
.iter()
.enumerate()
.rev()
.take_while(|(_, e)| e.path(self) == path)
.last()
.map(|(idx, _)| idx),
}
}
fn entry_index_by_idx_and_stage(
&self,
path: &BStr,
idx: usize,
wanted_stage: entry::StageRaw,
stage_cmp: Ordering,
) -> Option<usize> {
match stage_cmp {
Ordering::Greater => self.entries[..idx]
.iter()
.enumerate()
.rev()
.take_while(|(_, e)| e.path(self) == path)
.find_map(|(idx, e)| (e.stage_raw() == wanted_stage).then_some(idx)),
Ordering::Equal => Some(idx),
Ordering::Less => self
.entries
.get(idx + 1..)?
.iter()
.enumerate()
.take_while(|(_, e)| e.path(self) == path)
.find_map(|(ofs, e)| (e.stage_raw() == wanted_stage).then_some(idx + ofs + 1)),
}
}
/// Return a data structure to help with case-insensitive lookups.
///
/// It's required perform any case-insensitive lookup.
/// TODO: needs multi-threaded insertion, raw-table to have multiple locks depending on bucket.
pub fn prepare_icase_backing(&self) -> AccelerateLookup<'_> {
let _span = gix_trace::detail!("prepare_icase_backing", entries = self.entries.len());
let mut out = AccelerateLookup::with_capacity(self.entries.len());
for entry in &self.entries {
let entry_path = entry.path(self);
let hash = AccelerateLookup::icase_hash(entry_path);
out.icase_entries
.insert_unique(hash, entry, |e| AccelerateLookup::icase_hash(e.path(self)));
if entry_is_dir(entry) {
out.icase_dirs.insert_unique(
hash,
crate::DirEntry {
entry,
dir_end: entry.path.end,
},
|dir| AccelerateLookup::icase_hash(dir.path(self)),
);
}
let mut last_pos = entry_path.len();
while let Some(slash_idx) = entry_path[..last_pos].rfind_byte(b'/') {
let dir = entry_path[..slash_idx].as_bstr();
last_pos = slash_idx;
let dir_range = entry.path.start..(entry.path.start + dir.len());
let hash = AccelerateLookup::icase_hash(dir);
if out
.icase_dirs
.find(hash, |dir| {
dir.path(self) == self.path_backing[dir_range.clone()].as_bstr()
})
.is_none()
{
out.icase_dirs.insert_unique(
hash,
crate::DirEntry {
entry,
dir_end: dir_range.end,
},
|dir| AccelerateLookup::icase_hash(dir.path(self)),
);
} else {
break;
}
}
}
gix_trace::debug!(directories = out.icase_dirs.len(), "stored directories");
out
}
/// Return the entry at `path` that is at the lowest available stage, using `lookup` for acceleration.
/// It must have been created from this instance, and was ideally kept up-to-date with it.
///
/// The `path` must use the repository-relative, slash-separated [`State`] path format.
///
/// If `ignore_case` is `true`, a case-insensitive (ASCII-folding only) search will be performed.
pub fn entry_by_path_icase<'a>(
&'a self,
path: &BStr,
ignore_case: bool,
lookup: &AccelerateLookup<'a>,
) -> Option<&'a Entry> {
lookup
.icase_entries
.find(AccelerateLookup::icase_hash(path), |e| {
let entry_path = e.path(self);
if entry_path == path {
return true;
}
if !ignore_case {
return false;
}
entry_path.eq_ignore_ascii_case(path)
})
.copied()
}
/// Return the entry (at any stage) that is inside `directory`, or `None`,
/// or a directory itself like a submodule or sparse directory, using `lookup` for acceleration.
///
/// The `directory` must use the repository-relative, slash-separated [`State`] path format.
///
/// If `ignore_case` is set, a case-insensitive (ASCII-folding only) search will be performed.
pub fn entry_closest_to_directory_or_directory_icase<'a>(
&'a self,
directory: &BStr,
ignore_case: bool,
lookup: &AccelerateLookup<'a>,
) -> Option<&'a Entry> {
lookup
.icase_dirs
.find(AccelerateLookup::icase_hash(directory), |dir| {
let dir_path = dir.path(self);
if dir_path == directory {
return true;
}
if !ignore_case {
return false;
}
dir_path.eq_ignore_ascii_case(directory)
})
.map(|dir| dir.entry)
}
/// Return the entry (at any stage) that is inside `directory`, or `None`,
/// or that is a directory itself like a submodule or sparse directory.
///
/// The `directory` must use the repository-relative, slash-separated [`State`] path format.
///
/// Note that this is a *case-sensitive* search.
pub fn entry_closest_to_directory_or_directory(&self, directory: &BStr) -> Option<&Entry> {
let idx = match self.entry_index_by_path(directory) {
Ok(idx) => {
let entry = &self.entries[idx];
return entry_is_dir(entry).then_some(entry);
}
Err(closest_idx) => closest_idx,
};
for entry in &self.entries[idx..] {
let path = entry.path(self);
if path.get(..directory.len())? != directory {
break;
}
let dir_char = path.get(directory.len())?;
if *dir_char > b'/' {
break;
}
if *dir_char < b'/' {
continue;
}
return Some(entry);
}
None
}
/// Check if `path` is a directory that contains entries in the index, or is a submodule.
///
/// The `path` must use the repository-relative, slash-separated [`State`] path format.
///
/// Returns `true` if there is at least one entry in the index whose path starts with `path/`,
/// indicating that `path` is a directory containing indexed files.
///
/// For example, if the index contains an entry at `dirname/file`, then calling this method
/// with `dirname` would return `true`, but calling it with `dir` would return `false`.
///
/// Note that this is a case-sensitive search.
pub fn path_is_directory(&self, path: &BStr) -> bool {
self.entry_closest_to_directory_or_directory(path).is_some()
}
/// Check if `path` is a directory that contains entries in the index or is a submodule,
/// with optional case-insensitive matching.
///
/// The `path` must use the repository-relative, slash-separated [`State`] path format.
///
/// Returns `true` if there is at least one entry in the index whose path starts with `path/`,
/// indicating that `path` is a directory containing indexed files.
///
/// If `ignore_case` is `true`, a case-insensitive (ASCII-folding only) search will be performed.
///
/// For example, if the index contains an entry at `dirname/file`, then calling this method
/// with `dirname` (or `DirName` with `ignore_case = true`) would return `true`, but calling it
/// with `dir` would return `false`.
pub fn path_is_directory_icase<'a>(
&'a self,
path: &BStr,
ignore_case: bool,
lookup: &AccelerateLookup<'a>,
) -> bool {
self.entry_closest_to_directory_or_directory_icase(path, ignore_case, lookup)
.is_some()
}
/// Find the entry index in [`entries()[..upper_bound]`][State::entries()] matching the given `path` and `stage`,
/// or `None`.
///
/// The `path` must use the repository-relative, slash-separated [`State`] path format.
///
/// Use the index for accessing multiple stages if they exists, but at least the single matching entry.
///
/// # Panics
///
/// If `upper_bound` is out of bounds of our entries array.
pub fn entry_index_by_path_and_stage_bounded(
&self,
path: &BStr,
stage: entry::Stage,
upper_bound: usize,
) -> Option<usize> {
self.entries[..upper_bound]
.binary_search_by(|e| e.path(self).cmp(path).then_with(|| e.stage().cmp(&stage)))
.ok()
}
/// Like [`entry_index_by_path_and_stage()`](State::entry_index_by_path_and_stage()),
/// but returns the entry instead of the index.
///
/// The `path` must use the repository-relative, slash-separated [`State`] path format.
pub fn entry_by_path_and_stage(&self, path: &BStr, stage: entry::Stage) -> Option<&Entry> {
self.entry_index_by_path_and_stage(path, stage)
.map(|idx| &self.entries[idx])
}
/// Return the entry at `path` that is either at stage 0, or at stage 2 (ours) in case of a merge conflict.
///
/// The `path` must use the repository-relative, slash-separated [`State`] path format.
///
/// Using this method is more efficient in comparison to doing two searches, one for stage 0 and one for stage 2.
pub fn entry_by_path(&self, path: &BStr) -> Option<&Entry> {
let mut stage_at_index = 0;
let idx = self
.entries
.binary_search_by(|e| {
let res = e.path(self).cmp(path);
if res.is_eq() {
stage_at_index = e.stage_raw();
}
res
})
.ok()?;
let idx = if stage_at_index == 0 || stage_at_index == 2 {
idx
} else {
self.entry_index_by_idx_and_stage(path, idx, Stage::Ours as StageRaw, stage_at_index.cmp(&2))?
};
Some(&self.entries[idx])
}
/// Return the index at `Ok(index)` where the entry matching `path` (in any stage) can be found, or return
/// `Err(index)` to indicate the insertion position at which an entry with `path` would fit in.
///
/// The `path` must use the repository-relative, slash-separated [`State`] path format.
pub fn entry_index_by_path(&self, path: &BStr) -> Result<usize, usize> {
self.entries.binary_search_by(|e| e.path(self).cmp(path))
}
/// Return the slice of entries which all share the same `prefix`, or `None` if there isn't a single such entry.
///
/// The `prefix` must use the repository-relative, slash-separated [`State`] path format.
///
/// If `prefix` is empty, all entries are returned.
pub fn prefixed_entries(&self, prefix: &BStr) -> Option<&[Entry]> {
self.prefixed_entries_range(prefix).map(|range| &self.entries[range])
}
/// Return the range of entries which all share the same `prefix`, or `None` if there isn't a single such entry.
///
/// The `prefix` must use the repository-relative, slash-separated [`State`] path format.
///
/// If `prefix` is empty, the range will include all entries.
pub fn prefixed_entries_range(&self, prefix: &BStr) -> Option<Range<usize>> {
if prefix.is_empty() {
return Some(0..self.entries.len());
}
let prefix_len = prefix.len();
let mut low = self.entries.partition_point(|e| {
e.path(self)
.get(..prefix_len)
.map_or_else(|| e.path(self) <= &prefix[..e.path.len()], |p| p < prefix)
});
let mut high =
low + self.entries[low..].partition_point(|e| e.path(self).get(..prefix_len).is_some_and(|p| p <= prefix));
let low_entry = &self.entries.get(low)?;
if low_entry.stage_raw() != 0 {
low = self
.walk_entry_stages(low_entry.path(self), low, Ordering::Less)
.unwrap_or(low);
}
if let Some(high_entry) = self.entries.get(high)
&& high_entry.stage_raw() != 0
{
high = self
.walk_entry_stages(high_entry.path(self), high, Ordering::Less)
.unwrap_or(high);
}
(low != high).then_some(low..high)
}
/// Return the entry at `idx` or _panic_ if the index is out of bounds.
///
/// The `idx` is typically returned by [`entry_by_path_and_stage()`][State::entry_by_path_and_stage()].
pub fn entry(&self, idx: usize) -> &Entry {
&self.entries[idx]
}
/// Returns a boolean value indicating whether the index is sparse or not.
///
/// An index is sparse if it contains at least one [`Mode::DIR`][entry::Mode::DIR] entry.
pub fn is_sparse(&self) -> bool {
self.is_sparse
}
/// Return the range of entries that exactly match the given `path`, in all available stages, or `None` if no entry with such
/// path exists.
///
/// The `path` must use the repository-relative, slash-separated [`State`] path format.
///
/// The range can be used to access the respective entries via [`entries()`](Self::entries()) or [`entries_mut()](Self::entries_mut()).
pub fn entry_range(&self, path: &BStr) -> Option<Range<usize>> {
let mut stage_at_index = 0;
let idx = self
.entries
.binary_search_by(|e| {
let res = e.path(self).cmp(path);
if res.is_eq() {
stage_at_index = e.stage_raw();
}
res
})
.ok()?;
let (start, end) = (
self.walk_entry_stages(path, idx, Ordering::Less).unwrap_or(idx),
self.walk_entry_stages(path, idx, Ordering::Greater).unwrap_or(idx) + 1,
);
Some(start..end)
}
}
impl AccelerateLookup<'_> {
fn with_capacity(cap: usize) -> Self {
let ratio_of_entries_to_dirs_in_webkit = 20; // 400k entries and 20k dirs
Self {
icase_entries: hashbrown::HashTable::with_capacity(cap),
icase_dirs: hashbrown::HashTable::with_capacity(cap / ratio_of_entries_to_dirs_in_webkit),
}
}
fn icase_hash(data: &BStr) -> u64 {
use std::hash::Hasher;
let mut hasher = fnv::FnvHasher::default();
for b in data.as_bytes() {
hasher.write_u8(b.to_ascii_lowercase());
}
hasher.finish()
}
}
/// Mutation
impl State {
/// After usage of the storage obtained by [`take_path_backing()`][Self::take_path_backing()], return it here.
/// Note that it must not be empty.
pub fn return_path_backing(&mut self, backing: PathStorage) {
debug_assert!(
self.path_backing.is_empty(),
"BUG: return path backing only after taking it, once"
);
self.path_backing = backing;
}
/// Return mutable entries in a slice.
pub fn entries_mut(&mut self) -> &mut [Entry] {
&mut self.entries
}
/// Return a writable slice to entries and read-access to their path storage at the same time.
pub fn entries_mut_and_pathbacking(&mut self) -> (&mut [Entry], &PathStorageRef) {
(&mut self.entries, &self.path_backing)
}
/// Return mutable entries along with their paths in an iterator.
pub fn entries_mut_with_paths(&mut self) -> impl Iterator<Item = (&mut Entry, &BStr)> {
let paths = &self.path_backing;
self.entries.iter_mut().map(move |e| {
let path = paths[e.path.clone()].as_bstr();
(e, path)
})
}
/// Return all parts that relate to entries, which includes path storage.
///
/// This can be useful for obtaining a standalone, boxable iterator
pub fn into_entries(self) -> (Vec<Entry>, PathStorage) {
(self.entries, self.path_backing)
}
/// Sometimes it's needed to remove the path backing to allow certain mutation to happen in the state while supporting reading the entry's
/// path.
pub fn take_path_backing(&mut self) -> PathStorage {
assert_eq!(
self.entries.is_empty(),
self.path_backing.is_empty(),
"BUG: cannot take out backing multiple times"
);
std::mem::take(&mut self.path_backing)
}
/// Like [`entry_index_by_path_and_stage()`][State::entry_index_by_path_and_stage()],
/// but returns the mutable entry instead of the index.
///
/// The `path` must use the repository-relative, slash-separated [`State`] path format.
pub fn entry_mut_by_path_and_stage(&mut self, path: &BStr, stage: entry::Stage) -> Option<&mut Entry> {
self.entry_index_by_path_and_stage(path, stage)
.map(|idx| &mut self.entries[idx])
}
/// Push a new entry containing `stat`, `id`, `flags` and `mode` and `path` to the end of our storage, without performing
/// any sanity checks. This means it's possible to push a new entry to the same path on the same stage and even after sorting
/// the entries lookups may still return the wrong one of them unless the correct binary search criteria is chosen.
///
/// The `path` must use the repository-relative, slash-separated [`State`] path format.
///
/// Note that this *is likely* to break invariants that will prevent further lookups by path unless
/// [`entry_index_by_path_and_stage_bounded()`][State::entry_index_by_path_and_stage_bounded()] is used with
/// the `upper_bound` being the amount of entries before the first call to this method.
///
/// Alternatively, make sure to call [`sort_entries()`][State::sort_entries()] before entry lookup by path to restore
/// the invariant.
pub fn dangerously_push_entry(
&mut self,
stat: entry::Stat,
id: gix_hash::ObjectId,
flags: entry::Flags,
mode: entry::Mode,
path: &BStr,
) {
let path = {
let path_start = self.path_backing.len();
self.path_backing.push_str(path);
path_start..self.path_backing.len()
};
self.entries.push(Entry {
stat,
id,
flags,
mode,
path,
});
}
/// Unconditionally sort entries as needed to perform lookups quickly.
pub fn sort_entries(&mut self) {
let path_backing = &self.path_backing;
self.entries.sort_by(|a, b| {
Entry::cmp_filepaths(a.path_in(path_backing), b.path_in(path_backing))
.then_with(|| a.stage().cmp(&b.stage()))
});
}
/// Similar to [`sort_entries()`][State::sort_entries()], but applies `compare` after comparing
/// by path and stage as a third criteria.
pub fn sort_entries_by(&mut self, mut compare: impl FnMut(&Entry, &Entry) -> Ordering) {
let path_backing = &self.path_backing;
self.entries.sort_by(|a, b| {
Entry::cmp_filepaths(a.path_in(path_backing), b.path_in(path_backing))
.then_with(|| a.stage().cmp(&b.stage()))
.then_with(|| compare(a, b))
});
}
/// Physically remove all entries for which `should_remove(idx, path, entry)` returns `true`, traversing them from first to last.
///
/// Note that the memory used for the removed entries paths is not freed, as it's append-only, and
/// that some extensions might refer to paths which are now deleted.
///
/// ### Performance
///
/// To implement this operation typically, one would rather add [entry::Flags::REMOVE] to each entry to remove
/// them when [writing the index](Self::write_to()).
pub fn remove_entries(&mut self, mut should_remove: impl FnMut(usize, &BStr, &mut Entry) -> bool) {
let mut index = 0;
let paths = &self.path_backing;
self.entries.retain_mut(|e| {
let path = e.path_in(paths);
let res = !should_remove(index, path, e);
index += 1;
res
});
}
/// Physically remove the entry at `index`, or panic if the entry didn't exist.
///
/// This call is typically made after looking up `index`, so it's clear that it will not panic.
///
/// Note that the memory used for the removed entries paths is not freed, as it's append-only, and
/// that some extensions might refer to paths which are now deleted.
pub fn remove_entry_at_index(&mut self, index: usize) -> Entry {
self.entries.remove(index)
}
}
/// Extensions
impl State {
/// Access the `tree` extension.
pub fn tree(&self) -> Option<&extension::Tree> {
self.tree.as_ref()
}
/// Mutably access the `tree` extension.
pub fn tree_mut(&mut self) -> Option<&mut extension::Tree> {
self.tree.as_mut()
}
/// Remove the `tree` extension.
pub fn remove_tree(&mut self) -> Option<extension::Tree> {
self.tree.take()
}
/// Access the `link` extension.
pub fn link(&self) -> Option<&extension::Link> {
self.link.as_ref()
}
/// Obtain the resolve-undo extension.
pub fn resolve_undo(&self) -> Option<&extension::resolve_undo::Paths> {
self.resolve_undo.as_ref()
}
/// Remove the resolve-undo extension.
pub fn remove_resolve_undo(&mut self) -> Option<extension::resolve_undo::Paths> {
self.resolve_undo.take()
}
/// Obtain the untracked extension.
pub fn untracked(&self) -> Option<&extension::UntrackedCache> {
self.untracked.as_ref()
}
/// Obtain the fsmonitor extension.
pub fn fs_monitor(&self) -> Option<&extension::FsMonitor> {
self.fs_monitor.as_ref()
}
/// Return `true` if the end-of-index extension was present when decoding this index.
pub fn had_end_of_index_marker(&self) -> bool {
self.end_of_index_at_decode_time
}
/// Return `true` if the offset-table extension was present when decoding this index.
pub fn had_offset_table(&self) -> bool {
self.offset_table_at_decode_time
}
}
fn entry_is_dir(entry: &Entry) -> bool {
entry.mode.is_sparse() || entry.mode.is_submodule()
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
#[test]
fn entry_by_path_with_conflicting_file() {
let file = PathBuf::from("tests")
.join("fixtures")
.join(Path::new("loose_index").join("conflicting-file.git-index"));
let file = crate::File::at(file, gix_hash::Kind::Sha1, false, Default::default()).expect("valid file");
assert_eq!(
file.entries().len(),
3,
"we have a set of conflict entries for a single file"
);
for idx in 0..3 {
for wanted_stage in 1..=3 {
let actual_idx = file
.entry_index_by_idx_and_stage(
"file".into(),
idx,
wanted_stage,
(idx + 1).cmp(&(wanted_stage as usize)),
)
.expect("found");
assert_eq!(
actual_idx + 1,
wanted_stage as usize,
"the index and stage have a relation, and that is upheld if we search correctly"
);
}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
/// Configuration related to sparse indexes.
#[derive(Debug, Default, Clone, Copy)]
pub struct Options {
/// If true, certain entries in the index will be excluded / skipped for certain operations,
/// based on the ignore patterns in the `.git/info/sparse-checkout` file. These entries will
/// carry the [`SKIP_WORKTREE`][crate::entry::Flags::SKIP_WORKTREE] flag.
///
/// This typically is the value of `core.sparseCheckout` in the git configuration.
pub sparse_checkout: bool,
/// Interpret the `.git/info/sparse-checkout` file using _cone mode_.
///
/// If true, _cone mode_ is active and entire directories will be included in the checkout, as well as files in the root
/// of the repository.
/// If false, non-cone mode is active and entries to _include_ will be matched with patterns like those found in `.gitignore` files.
///
/// This typically is the value of `core.sparseCheckoutCone` in the git configuration.
pub directory_patterns_only: bool,
/// If true, will attempt to write a sparse index file which only works in cone mode.
///
/// A sparse index has [`DIR` entries][crate::entry::Mode::DIR] that represent entire directories to be skipped
/// during checkout and other operations due to the added presence of
/// the [`SKIP_WORKTREE`][crate::entry::Flags::SKIP_WORKTREE] flag.
///
/// This is typically the value of `index.sparse` in the git configuration.
pub write_sparse_index: bool,
}
impl Options {
/// Derive a valid mode from all parameters that affect the 'sparseness' of the index.
///
/// Some combinations of them degenerate to one particular mode.
pub fn sparse_mode(&self) -> Mode {
match (
self.sparse_checkout,
self.directory_patterns_only,
self.write_sparse_index,
) {
(true, true, true) => Mode::IncludeDirectoriesStoreIncludedEntriesAndExcludedDirs,
(true, true, false) => Mode::IncludeDirectoriesStoreAllEntriesSkipUnmatched,
(true, false, _) => Mode::IncludeByIgnorePatternStoreAllEntriesSkipUnmatched,
(false, _, _) => Mode::Disabled,
}
}
}
/// Describes the configuration how a sparse index should be written, or if one should be written at all.
#[derive(Debug)]
pub enum Mode {
/// index with DIR entries for exclusion and included entries, directory-only include patterns in `.git/info/sparse-checkout` file.
IncludeDirectoriesStoreIncludedEntriesAndExcludedDirs,
/// index with all file entries and skip worktree flags for exclusion, directory-only include patterns in `.git/info/sparse-checkout` file.
IncludeDirectoriesStoreAllEntriesSkipUnmatched,
/// index with all file entries and skip-worktree flags for exclusion, `ignore` patterns to include entries in `.git/info/sparse-checkout` file.
IncludeByIgnorePatternStoreAllEntriesSkipUnmatched,
/// index with all entries, non is excluded, `.git/info/sparse-checkout` file is not considered, a regular index.
Disabled,
}
+234
View File
@@ -0,0 +1,234 @@
use std::ops::Range;
use gix_error::{OptionExt, Result};
use crate::{
Entry, Version,
decode::header,
entry,
util::{read_u32, split_at_byte_exclusive, var_int},
};
/// a guess directly from git sources
pub const AVERAGE_V4_DELTA_PATH_LEN_IN_BYTES: usize = 80;
pub struct Outcome {
pub is_sparse: bool,
}
fn entries_block_size_in_bytes(
on_disk_size: usize,
offset_to_extensions: Option<usize>,
object_hash: gix_hash::Kind,
) -> usize {
offset_to_extensions
.unwrap_or_else(|| on_disk_size.saturating_sub(object_hash.len_in_bytes()))
.saturating_sub(header::SIZE)
}
const fn on_disk_entry_sans_path(object_hash: gix_hash::Kind) -> usize {
8 + // ctime
8 + // mtime
(4 * 6) + // various stat fields
2 + // flag, ignore extended flag as we'd rather overallocate a bit
object_hash.len_in_bytes()
}
/// Return a lower bound for the number of on-disk bytes one entry must occupy,
/// based on `object_hash` and `version`.
///
/// This is used to reject impossible entry counts and to cap estimates derived from untrusted data.
/// For V2/V3, entries are padded to 8-byte boundaries, so the fixed payload is rounded up to the
/// next multiple of 8. For V4, paths are delta-encoded, but each entry still
/// needs at least a one-byte strip-length varint plus the trailing NUL of the path suffix, hence
/// `size + 2`.
const fn min_entry_size_in_bytes(object_hash: gix_hash::Kind, version: Version) -> usize {
let size = on_disk_entry_sans_path(object_hash);
match version {
Version::V2 | Version::V3 => size.next_multiple_of(8),
Version::V4 => size + 2,
}
}
/// Compute an upper bound for how many entries can physically fit into the on-disk entries block.
///
/// The entries block is the index payload after the header and before extensions or, if there are no
/// extensions, before the trailing checksum. We divide that byte budget by [`min_entry_size_in_bytes()`]
/// to obtain the largest plausible entry count for the declared index version. This is intentionally a
/// coarse upper bound used to reject corrupt headers that claim more entries than the remaining bytes
/// could possibly encode.
pub fn max_entries_possible(
on_disk_size: usize,
offset_to_extensions: Option<usize>,
object_hash: gix_hash::Kind,
version: Version,
) -> usize {
entries_block_size_in_bytes(on_disk_size, offset_to_extensions, object_hash)
/ min_entry_size_in_bytes(object_hash, version)
}
pub fn estimate_path_storage_requirements_in_bytes(
num_entries: u32,
on_disk_size: usize,
offset_to_extensions: Option<usize>,
object_hash: gix_hash::Kind,
version: Version,
) -> usize {
let size_of_entries_block = entries_block_size_in_bytes(on_disk_size, offset_to_extensions, object_hash);
match version {
Version::V3 | Version::V2 => {
// V2/V3 store full paths in the entries block, so whatever remains after subtracting the fixed
// entry header portion is an upper bound for path bytes we may copy into memory.
size_of_entries_block.saturating_sub(num_entries as usize * on_disk_entry_sans_path(object_hash))
}
Version::V4 => size_of_entries_block
// V4 stores delta-compressed paths on disk. Subtract the minimum per-entry non-path payload to
// validate how many on-disk bytes could plausibly remain for path suffixes at all.
.saturating_sub(num_entries as usize * (on_disk_entry_sans_path(object_hash) + 2))
// The in-memory backing stores expanded paths, so the on-disk remainder is not a good estimate by
// itself. Keep the historic per-entry heuristic, but clamp it to what the entries block can plausibly
// contain so corrupt entry counts cannot drive an excessive preallocation.
.min(num_entries as usize * AVERAGE_V4_DELTA_PATH_LEN_IN_BYTES),
}
}
/// Note that `data` must point to the beginning of the entries, right past the header.
pub fn chunk<'a>(
mut data: &'a [u8],
entries: &mut Vec<Entry>,
path_backing: &mut Vec<u8>,
num_entries: u32,
object_hash: gix_hash::Kind,
version: Version,
) -> Result<(Outcome, &'a [u8])> {
let mut is_sparse = false;
let has_delta_paths = version == Version::V4;
let mut prev_path = None;
let mut delta_buf = Vec::<u8>::with_capacity(AVERAGE_V4_DELTA_PATH_LEN_IN_BYTES);
for idx in 0..num_entries {
let (entry, remaining) = load_one(
data,
path_backing,
object_hash.len_in_bytes(),
has_delta_paths,
prev_path,
)
.ok_or_raise(|| gix_error::message!("Could not parse entry at index {idx}").corrupted())?;
data = remaining;
is_sparse |= entry.mode.is_sparse();
// TODO: entries are actually in an intrusive collection, with path as key. Could be set for us. This affects 'ignore_case' which we
// also don't yet handle but probably could, maybe even smartly with the collection.
// For now it's unclear to me how they access the index, they could iterate quickly, and have fast access by path.
entries.push(entry);
prev_path = entries.last().map(|e| (e.path.clone(), &mut delta_buf));
}
Ok((Outcome { is_sparse }, data))
}
/// Note that `prev_path` is only useful if the version is V4
fn load_one<'a>(
data: &'a [u8],
path_backing: &mut Vec<u8>,
hash_len: usize,
has_delta_paths: bool,
prev_path_and_buf: Option<(Range<usize>, &mut Vec<u8>)>,
) -> Option<(Entry, &'a [u8])> {
let first_byte_of_entry = data.as_ptr() as usize;
let (ctime_secs, data) = read_u32(data)?;
let (ctime_nsecs, data) = read_u32(data)?;
let (mtime_secs, data) = read_u32(data)?;
let (mtime_nsecs, data) = read_u32(data)?;
let (dev, data) = read_u32(data)?;
let (ino, data) = read_u32(data)?;
let (mode, data) = read_u32(data)?;
let (uid, data) = read_u32(data)?;
let (gid, data) = read_u32(data)?;
let (size, data) = read_u32(data)?;
let (hash, data) = data.split_at_checked(hash_len)?;
let (flags, data) = read_u16(data)?;
let flags = entry::at_rest::Flags::from_bits_retain(flags);
let (flags, data) = if flags.contains(entry::at_rest::Flags::EXTENDED) {
let (extended_flags, data) = read_u16(data)?;
let extended_flags = entry::at_rest::FlagsExtended::from_bits(extended_flags)?;
let extended_flags = extended_flags.to_flags()?;
(flags.to_memory() | extended_flags, data)
} else {
(flags.to_memory(), data)
};
let start = path_backing.len();
let data = if has_delta_paths {
let (strip_len, data) = var_int(data)?;
if let Some((prev_path, buf)) = prev_path_and_buf {
let end = prev_path.end.checked_sub(strip_len.try_into().ok()?)?;
let copy_len = end.checked_sub(prev_path.start)?;
if copy_len > 0 {
buf.resize(copy_len, 0);
buf.copy_from_slice(&path_backing[prev_path.start..end]);
path_backing.extend_from_slice(buf);
}
}
let (path, data) = split_at_byte_exclusive(data, 0)?;
path_backing.extend_from_slice(path);
data
} else {
let (path, data) = if flags.contains(entry::Flags::PATH_LEN) {
split_at_byte_exclusive(data, 0)?
} else {
let path_len = (flags.bits() & entry::Flags::PATH_LEN.bits()) as usize;
let (path, data) = data.split_at_checked(path_len)?;
(path, skip_padding(data, first_byte_of_entry)?)
};
// TODO(perf): for some reason, this causes tremendous `memmove` time even though the backing
// has enough capacity most of the time.
path_backing.extend_from_slice(path);
data
};
let path_range = start..path_backing.len();
Some((
Entry {
stat: entry::Stat {
ctime: entry::stat::Time {
secs: ctime_secs,
nsecs: ctime_nsecs,
},
mtime: entry::stat::Time {
secs: mtime_secs,
nsecs: mtime_nsecs,
},
dev,
ino,
uid,
gid,
size,
},
id: gix_hash::ObjectId::from_bytes_or_panic(hash),
flags: flags & !entry::Flags::PATH_LEN,
// This forces us to add the bits we need before being able to use them.
mode: entry::Mode::from_bits_truncate(mode),
path: path_range,
},
data,
))
}
#[inline]
fn skip_padding(data: &[u8], first_byte_of_entry: usize) -> Option<&[u8]> {
let current_offset = data.as_ptr() as usize;
let c_padding = (current_offset - first_byte_of_entry + 8) & !7;
let skip = (first_byte_of_entry + c_padding) - current_offset;
data.get(skip..)
}
#[inline]
fn read_u16(data: &[u8]) -> Option<(u16, &[u8])> {
data.split_at_checked(2)
.map(|(num, data)| (u16::from_be_bytes(num.try_into().unwrap()), data))
}
+35
View File
@@ -0,0 +1,35 @@
use gix_error::{Result, bail, corruption};
pub(crate) const SIZE: usize = 4 /*signature*/ + 4 /*version*/ + 4 /* num entries */;
use crate::{Version, util::from_be_u32};
pub(crate) const SIGNATURE: &[u8] = b"DIRC";
pub(crate) fn decode(data: &[u8], object_hash: gix_hash::Kind) -> Result<(Version, u32, &[u8])> {
if data.len() < (3 * 4) + object_hash.len_in_bytes() {
bail!(corruption(
"File is too small even for header with zero entries and smallest hash"
));
}
let (signature, data) = data.split_at(4);
if signature != SIGNATURE {
bail!(corruption(
"Signature mismatch - this doesn't claim to be a header file"
));
}
let (version, data) = data.split_at(4);
let version = match from_be_u32(version) {
2 => Version::V2,
3 => Version::V3,
4 => Version::V4,
unknown => {
bail!("Index version {unknown} is not supported".unsupported());
}
};
let (entries, data) = data.split_at(4);
let entries = from_be_u32(entries);
Ok((version, entries, data))
}
+349
View File
@@ -0,0 +1,349 @@
use filetime::FileTime;
use gix_error::{Error, Result};
use crate::{Entry, State, Version, entry, extension};
mod entries;
///
pub mod header;
use gix_error::{ResourceExhaustionKind, ResultExt, bail, corruption, message};
use gix_parallel::InOrderIter;
use crate::util::read_u32;
/// Options to define how to decode an index state [from bytes][State::from_bytes()].
#[derive(Debug, Default, Clone, Copy)]
pub struct Options {
/// If Some(_), we are allowed to use more than one thread. If Some(N), use no more than N threads. If Some(0)|None, use as many threads
/// as there are logical cores.
///
/// This applies to loading extensions in parallel to entries if the common EOIE extension is available.
/// It also allows to use multiple threads for loading entries if the IEOT extension is present.
pub thread_limit: Option<usize>,
/// The minimum size in bytes to load extensions in their own thread, assuming there is enough `num_threads` available.
/// If set to 0, for example, extensions will always be read in their own thread if enough threads are available.
pub min_extension_block_in_bytes_for_threading: usize,
/// Set the expected hash of this index if we are read as part of a `link` extension.
///
/// We will abort reading this file if it doesn't match.
pub expected_checksum: Option<gix_hash::ObjectId>,
/// Configure the maximum size of a single allocation caused by untrusted on-disk index data.
///
/// Use `None` to disable the limit, which is also the default.
pub alloc_limit_bytes: Option<usize>,
}
impl State {
/// Decode an index state from `data` and store `timestamp` in the resulting instance for pass-through, assuming `object_hash`
/// to be used through the file. Also return the stored hash over all bytes in `data` or `None` if none was written due to `index.skipHash`.
pub fn from_bytes(
data: &[u8],
timestamp: FileTime,
object_hash: gix_hash::Kind,
_options @ Options {
thread_limit,
min_extension_block_in_bytes_for_threading,
expected_checksum,
alloc_limit_bytes,
}: Options,
) -> Result<(Self, Option<gix_hash::ObjectId>)> {
let _span = gix_trace::detail!("gix_index::State::from_bytes()", options = ?_options);
let (version, num_entries, post_header_data) = header::decode(data, object_hash)?;
let start_of_extensions = extension::end_of_index_entry::decode(data, object_hash)?;
if num_entries as usize > entries::max_entries_possible(data.len(), start_of_extensions, object_hash, version) {
bail!(corruption(
"Declared entry count exceeds possible entries for file size"
));
}
let mut num_threads = gix_parallel::num_threads(thread_limit);
let path_backing_buffer_size = entries::estimate_path_storage_requirements_in_bytes(
num_entries,
data.len(),
start_of_extensions,
object_hash,
version,
);
ensure_in_alloc_limit(
(num_entries as usize)
.checked_mul(std::mem::size_of::<Entry>())
.ok_or_else(|| allocation_error(ResourceExhaustionKind::AllocationFailure))?,
alloc_limit_bytes,
)?;
ensure_in_alloc_limit(path_backing_buffer_size, alloc_limit_bytes)?;
let (entries, ext, data) = match start_of_extensions {
Some(offset) if num_threads > 1 => {
let extensions_data = &data[offset..];
let index_offsets_table = extension::index_entry_offset_table::find(extensions_data, object_hash);
let (entries_res, ext_res) = gix_parallel::threads(|scope| {
let extension_loading =
(extensions_data.len() > min_extension_block_in_bytes_for_threading).then({
num_threads -= 1;
|| {
gix_parallel::build_thread()
.name("gix-index.from_bytes.load-extensions".into())
.spawn_scoped(
scope,
gix_trace::in_thread(|| {
extension::decode::all(extensions_data, object_hash, alloc_limit_bytes)
}),
)
.expect("valid name")
}
});
let entries_res = match index_offsets_table {
Some(entry_offsets) => {
let chunk_size = (entry_offsets.len() as f32 / num_threads as f32).ceil() as usize;
let entry_offsets_chunked = entry_offsets.chunks(chunk_size);
let num_chunks = entry_offsets_chunked.len();
let mut threads = Vec::with_capacity(num_chunks);
for (id, chunks) in entry_offsets_chunked.enumerate() {
let chunks = chunks.to_vec();
threads.push(
gix_parallel::build_thread()
.name(format!("gix-index.from_bytes.read-entries.{id}"))
.spawn_scoped(
scope,
gix_trace::in_thread(move || {
let num_entries_for_chunks =
chunks.iter().map(|c| c.num_entries).sum::<u32>() as usize;
let mut entries = vec_with_capacity(num_entries_for_chunks)?;
let path_backing_buffer_size_for_chunks =
entries::estimate_path_storage_requirements_in_bytes(
num_entries_for_chunks as u32,
data.len() / num_chunks,
start_of_extensions.map(|ofs| ofs / num_chunks),
object_hash,
version,
);
let mut path_backing =
vec_with_capacity(path_backing_buffer_size_for_chunks)?;
let mut is_sparse = false;
for offset in chunks {
let (
entries::Outcome {
is_sparse: chunk_is_sparse,
},
_data,
) = entries::chunk(
&data[offset.from_beginning_of_file as usize..],
&mut entries,
&mut path_backing,
offset.num_entries,
object_hash,
version,
)?;
is_sparse |= chunk_is_sparse;
}
Ok::<_, Error>((
id,
EntriesOutcome {
entries,
path_backing,
is_sparse,
},
))
}),
)
.expect("valid name"),
);
}
let mut results =
InOrderIter::from(threads.into_iter().map(|thread| thread.join().unwrap()));
let mut acc = results.next().expect("have at least two results, one per thread");
// We explicitly don't adjust the reserve in acc and rather allow for more copying
// to happens as vectors grow to keep the peak memory size low.
// NOTE: one day, we might use a memory pool for paths. We could encode the block of memory
// in some bytes in the path offset. That way there is more indirection/slower access
// to the path, but it would save time here.
// As it stands, `git` is definitely more efficient at this and probably uses less memory too.
// Maybe benchmarks can tell if that is noticeable later at 200/400GB/s memory bandwidth, or maybe just
// 100GB/s on a single core.
while let (Ok(lhs), Some(res)) = (acc.as_mut(), results.next()) {
match res {
Ok(mut rhs) => {
lhs.is_sparse |= rhs.is_sparse;
let ofs = lhs.path_backing.len();
lhs.path_backing.append(&mut rhs.path_backing);
lhs.entries.extend(rhs.entries.into_iter().map(|mut e| {
e.path.start += ofs;
e.path.end += ofs;
e
}));
}
Err(err) => {
acc = Err(err);
}
}
}
acc.map(|acc| (acc, &data[data.len() - object_hash.len_in_bytes()..]))
}
None => entries(
post_header_data,
path_backing_buffer_size,
num_entries,
object_hash,
version,
),
};
let ext_res = extension_loading.map_or_else(
|| extension::decode::all(extensions_data, object_hash, alloc_limit_bytes),
|thread| thread.join().unwrap(),
);
(entries_res, ext_res)
});
let (ext, data) = ext_res?;
(entries_res?.0, ext, data)
}
None | Some(_) => {
let (entries, data) = entries(
post_header_data,
path_backing_buffer_size,
num_entries,
object_hash,
version,
)?;
let (ext, data) = extension::decode::all(data, object_hash, alloc_limit_bytes)?;
(entries, ext, data)
}
};
if data.len() != object_hash.len_in_bytes() {
bail!(corruption(format!(
"Index trailer should have been {} bytes long, but was {}",
object_hash.len_in_bytes(),
data.len()
)));
}
let checksum = gix_hash::ObjectId::from_bytes_or_panic(data);
let checksum = (!checksum.is_null()).then_some(checksum);
if let Some((expected_checksum, actual_checksum)) = expected_checksum.zip(checksum) {
actual_checksum
.verify(&expected_checksum)
.or_raise(|| message("Shared index checksum mismatch"))?;
}
let EntriesOutcome {
entries,
path_backing,
mut is_sparse,
} = entries;
let extension::decode::Outcome {
tree,
link,
resolve_undo,
untracked,
fs_monitor,
is_sparse: is_sparse_from_ext, // a marker is needed in case there are no directories
end_of_index,
offset_table,
} = ext;
is_sparse |= is_sparse_from_ext;
Ok((
State {
object_hash,
timestamp,
version,
entries,
path_backing,
is_sparse,
end_of_index_at_decode_time: end_of_index,
offset_table_at_decode_time: offset_table,
tree,
link,
resolve_undo,
untracked,
fs_monitor,
},
checksum,
))
}
}
struct EntriesOutcome {
pub entries: Vec<Entry>,
pub path_backing: Vec<u8>,
pub is_sparse: bool,
}
fn vec_with_capacity<T>(capacity: usize) -> Result<Vec<T>> {
let mut vec = Vec::new();
vec.try_reserve(capacity)
.or_raise(|| message("Index data would require more memory than can be reserved"))?;
Ok(vec)
}
fn entries(
post_header_data: &[u8],
path_backing_buffer_size: usize,
num_entries: u32,
object_hash: gix_hash::Kind,
version: Version,
) -> Result<(EntriesOutcome, &[u8])> {
let mut entries = vec_with_capacity(num_entries as usize)?;
let mut path_backing = vec_with_capacity(path_backing_buffer_size)?;
entries::chunk(
post_header_data,
&mut entries,
&mut path_backing,
num_entries,
object_hash,
version,
)
.map(|(entries::Outcome { is_sparse }, data): (entries::Outcome, &[u8])| {
(
EntriesOutcome {
entries,
path_backing,
is_sparse,
},
data,
)
})
}
pub(crate) fn stat(data: &[u8]) -> Option<(entry::Stat, &[u8])> {
let (ctime_secs, data) = read_u32(data)?;
let (ctime_nsecs, data) = read_u32(data)?;
let (mtime_secs, data) = read_u32(data)?;
let (mtime_nsecs, data) = read_u32(data)?;
let (dev, data) = read_u32(data)?;
let (ino, data) = read_u32(data)?;
let (uid, data) = read_u32(data)?;
let (gid, data) = read_u32(data)?;
let (size, data) = read_u32(data)?;
Some((
entry::Stat {
ctime: entry::stat::Time {
secs: ctime_secs,
nsecs: ctime_nsecs,
},
mtime: entry::stat::Time {
secs: mtime_secs,
nsecs: mtime_nsecs,
},
dev,
ino,
uid,
gid,
size,
},
data,
))
}
fn ensure_in_alloc_limit(size: usize, alloc_limit_bytes: Option<usize>) -> Result {
if alloc_limit_bytes.is_some_and(|limit| size > limit) {
return Err(allocation_error(ResourceExhaustionKind::AllocationLimit));
}
Ok(())
}
fn allocation_error(kind: ResourceExhaustionKind) -> Error {
message("Index data would require more memory than can be reserved").resource_exhaustion_error(kind)
}
+175
View File
@@ -0,0 +1,175 @@
use bitflags::bitflags;
use crate::entry::Stage;
bitflags! {
/// In-memory flags.
///
/// Notably, not all of these will be persisted but can be used to aid all kinds of operations.
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
pub struct Flags: u32 {
// TODO: could we use the pathlen ourselves to save 8 bytes? And how to handle longer paths than that? 0 as sentinel maybe?
/// The mask to obtain the length of the path associated with this entry, up to 4095 characters without extension.
const PATH_LEN = 0x0fff;
/// The mask to apply to obtain the stage number of an entry, encoding three value: 0 = base, 1 = ours, 2 = theirs.
const STAGE_MASK = (1<<12) | (1<<13);
/// If set, additional bits need to be written to storage.
const EXTENDED = 1<<14;
/// If set, the entry be assumed to match with the version on the working tree, as a way to avoid `lstat()` checks.
const ASSUME_VALID = 1 << 15;
/// Indicates that an entry needs to be updated as it's in-memory representation doesn't match what's on disk.
const UPDATE = 1 << 16;
/// Indicates an entry should be removed - this typically happens during writing, by simply skipping over them.
const REMOVE = 1 << 17;
/// Indicates that an entry is known to be up-to-date.
const UPTODATE = 1 << 18;
/// Only temporarily used by unpack_trees() (in C).
const ADDED = 1 << 19;
/// Whether an up-to-date object hash exists for the entry.
const HASHED = 1 << 20;
/// Set if the filesystem monitor is valid.
const FSMONITOR_VALID = 1 << 21;
/// Remove in work directory
const WORKTREE_REMOVE = 1 << 22;
/// Set to indicate the entry exists in multiple stages at once due to conflicts.
const CONFLICTED = 1 << 23;
/// Indicates that the entry was already turned into a tree.
const UNPACKED = 1 << 24;
/// Only temporarily used by unpack_trees() (in C)
const NEW_SKIP_WORKTREE = 1 << 25;
/// temporarily mark paths matched by a path spec
const PATHSPEC_MATCHED = 1 << 26;
/// When the index is split, this indicates the entry is up-to-date in the shared portion of the index.
const UPDATE_IN_BASE = 1 << 27;
/// Indicates the entry name is present in the base/shared index, and thus doesn't have to be stored in this one.
const STRIP_NAME = 1 << 28;
/// Created with `git add --intent-to-add` to mark empty entries that have their counter-part in the worktree, but not
/// yet in the object database.
const INTENT_TO_ADD = 1 << 29;
/// Stored at rest
const SKIP_WORKTREE = 1 << 30;
/// For future extension
const EXTENDED_2 = 1 << 31;
}
}
impl Flags {
/// Create a new instance whose stage is set to `stage`.
pub fn from_stage(stage: Stage) -> Self {
Flags::from_bits((stage as u32) << 12).expect("stage can only be valid flags")
}
/// Return the stage as extracted from the bits of this instance.
pub fn stage(&self) -> Stage {
match self.stage_raw() {
0 => Stage::Unconflicted,
1 => Stage::Base,
2 => Stage::Ours,
3 => Stage::Theirs,
_ => unreachable!("BUG: Flags::STAGE_MASK is two bits, whose 4 possible values we have covered"),
}
}
/// Return an entry's stage as raw number between 0 and 4.
/// Possible values are:
///
/// * 0 = no conflict,
/// * 1 = base,
/// * 2 = ours,
/// * 3 = theirs
pub fn stage_raw(&self) -> u32 {
(*self & Flags::STAGE_MASK).bits() >> 12
}
/// Transform ourselves to a storage representation to keep all flags which are to be persisted,
/// skipping all extended flags. Note that the caller has to check for the `EXTENDED` bit to be present
/// and write extended flags as well if so.
pub fn to_storage(mut self) -> at_rest::Flags {
at_rest::Flags::from_bits_retain(
{
self.remove(Self::PATH_LEN);
self
}
.bits() as u16,
)
}
}
impl From<Stage> for Flags {
fn from(value: Stage) -> Self {
Flags::from_stage(value)
}
}
pub(crate) mod at_rest {
use bitflags::bitflags;
bitflags! {
/// Flags how they are serialized to a storage location
#[derive(Copy, Clone, Debug)]
pub struct Flags: u16 {
/// A portion of a the flags that encodes the length of the path that follows.
const PATH_LEN = 0x0fff;
const STAGE_MASK = 0x3000;
/// If set, there is more extended flags past this one
const EXTENDED = 0x4000;
/// If set, the entry be assumed to match with the version on the working tree, as a way to avoid `lstat()` checks.
const ASSUME_VALID = 0x8000;
}
}
impl Flags {
pub fn to_memory(self) -> super::Flags {
super::Flags::from_bits_retain(u32::from(self.bits()))
}
}
bitflags! {
/// Extended flags - add flags for serialization here and offset them down to u16.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct FlagsExtended: u16 {
const INTENT_TO_ADD = 1 << (29 - 16);
const SKIP_WORKTREE = 1 << (30 - 16);
}
}
impl FlagsExtended {
pub fn from_flags(flags: super::Flags) -> Self {
Self::from_bits_retain(
((flags & (super::Flags::INTENT_TO_ADD | super::Flags::SKIP_WORKTREE)).bits() >> 16) as u16,
)
}
pub fn to_flags(self) -> Option<super::Flags> {
super::Flags::from_bits(u32::from(self.bits()) << 16)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flags_extended_conversion() {
assert_eq!(
FlagsExtended::all().to_flags(),
Some(super::super::Flags::INTENT_TO_ADD | super::super::Flags::SKIP_WORKTREE)
);
assert_eq!(
FlagsExtended::from_flags(super::super::Flags::all()),
FlagsExtended::all()
);
}
#[test]
fn flags_from_bits_with_conflict() {
let input = 0b1110_0010_1000_1011;
assert_eq!(Flags::from_bits_retain(input).bits(), input);
}
}
}
+145
View File
@@ -0,0 +1,145 @@
/// The stage of an entry.
#[derive(Default, Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)]
pub enum Stage {
/// This is the default, and most entries are in this stage.
#[default]
Unconflicted = 0,
/// The entry is the common base between 'our' change and 'their' change, for comparison.
Base = 1,
/// The entry represents our change.
Ours = 2,
/// The entry represents their change.
Theirs = 3,
}
// The stage of an entry, one of…
/// * 0 = no conflict,
/// * 1 = base,
/// * 2 = ours,
/// * 3 = theirs
pub type StageRaw = u32;
///
pub mod mode;
mod flags;
pub use flags::Flags;
pub(crate) use flags::at_rest;
///
pub mod stat;
mod write;
use bitflags::bitflags;
bitflags! {
/// The kind of file of an entry.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd)]
pub struct Mode: u32 {
/// directory (only used for sparse checkouts), equivalent to a tree, which is _excluded_ from the index via
/// cone-mode.
const DIR = 0o040000;
/// regular file
const FILE = 0o100644;
/// regular file, executable
const FILE_EXECUTABLE = 0o100755;
/// Symbolic link
const SYMLINK = 0o120000;
/// A git commit for submodules
const COMMIT = 0o160000;
}
}
/// An entry's filesystem stat information.
#[derive(Debug, Default, PartialEq, Eq, Hash, Ord, PartialOrd, Clone, Copy)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Stat {
/// Modification time
pub mtime: stat::Time,
/// Creation time
pub ctime: stat::Time,
/// Device number
pub dev: u32,
/// Inode number
pub ino: u32,
/// User id of the owner
pub uid: u32,
/// Group id of the owning group
pub gid: u32,
/// The size of bytes on disk. Capped to u32 so files bigger than that will need thorough additional checking
pub size: u32,
}
mod access {
use bstr::{BStr, ByteSlice};
use crate::{Entry, State, entry};
impl Entry {
/// Return an entry's path, relative to the repository, which is extracted from its owning `state`.
pub fn path<'a>(&self, state: &'a State) -> &'a BStr {
state.path_backing[self.path.clone()].as_bstr()
}
/// Return an entry's path using the given `backing`.
pub fn path_in<'backing>(&self, backing: &'backing crate::PathStorageRef) -> &'backing BStr {
backing[self.path.clone()].as_bstr()
}
/// Return an entry's stage. See [entry::Stage] for possible values.
pub fn stage(&self) -> entry::Stage {
self.flags.stage()
}
/// Return an entry's stage as raw number between 0 and 4.
/// Possible values are:
///
/// * 0 = no conflict,
/// * 1 = base,
/// * 2 = ours,
/// * 3 = theirs
pub fn stage_raw(&self) -> u32 {
self.flags.stage_raw()
}
}
}
mod _impls {
use std::cmp::Ordering;
use bstr::BStr;
use gix_object::tree::EntryKind;
use crate::{Entry, State, entry};
impl From<EntryKind> for entry::Mode {
fn from(value: EntryKind) -> Self {
match value {
EntryKind::Tree => entry::Mode::DIR,
EntryKind::Blob => entry::Mode::FILE,
EntryKind::BlobExecutable => entry::Mode::FILE_EXECUTABLE,
EntryKind::Link => entry::Mode::SYMLINK,
EntryKind::Commit => entry::Mode::COMMIT,
}
}
}
impl Entry {
/// Compare one entry to another by their path, by comparing only their common path portion byte by byte, then resorting to
/// entry length and stage.
pub fn cmp(&self, other: &Self, state: &State) -> Ordering {
let lhs = self.path(state);
let rhs = other.path(state);
Entry::cmp_filepaths(lhs, rhs).then_with(|| self.stage().cmp(&other.stage()))
}
/// Compare one entry to another by their path, by comparing only their common path portion byte by byte, then resorting to
/// entry length.
pub fn cmp_filepaths(a: &BStr, b: &BStr) -> Ordering {
let common_len = a.len().min(b.len());
a[..common_len]
.cmp(&b[..common_len])
.then_with(|| a.len().cmp(&b.len()))
}
}
}
+144
View File
@@ -0,0 +1,144 @@
use crate::entry::Mode;
impl Mode {
/// Return `true` if this is a sparse entry, as it points to a directory which usually isn't what an 'unsparse' index tracks.
pub fn is_sparse(&self) -> bool {
*self == Self::DIR
}
/// Return `true` if this is a submodule entry.
pub fn is_submodule(&self) -> bool {
*self == Self::DIR | Self::SYMLINK
}
/// Return whether this entry is a directory for ignore and sparse-pattern matching.
/// Sparse directories and gitlinks are directories regardless of whether they exist on disk.
pub fn is_dir_for_pattern_matching(&self) -> bool {
self.is_sparse() || self.is_submodule()
}
/// Convert this instance to a tree's entry mode, or return `None` if for some
/// and unexpected reason the bitflags don't resemble any known entry-mode.
pub fn to_tree_entry_mode(&self) -> Option<gix_object::tree::EntryMode> {
gix_object::tree::EntryMode::try_from(self.bits()).ok()
}
/// Compares this mode to the file system version ([`std::fs::symlink_metadata`])
/// and returns the change needed to update this mode to match the file.
///
/// * if `has_symlinks` is false symlink entries will simply check if there
/// is a normal file on disk
/// * if `executable_bit` is false the executable bit will not be compared
/// `Change::ExecutableBit` will never be generated
///
/// If there is a type change then we will use whatever information is
/// present on the FS. Specifically if `has_symlinks` is false we will
/// never generate `Change::TypeChange { new_mode: Mode::SYMLINK }`. and
/// if `executable_bit` is false we will never generate `Change::TypeChange
/// { new_mode: Mode::FILE_EXECUTABLE }` (all files are assumed to be not
/// executable). That means that unstaging and staging files can be a lossy
/// operation on such file systems.
///
/// If a directory replaced a normal file/symlink we assume that the
/// directory is a submodule. Normal (non-submodule) directories would
/// cause a file to be deleted from the index and should be handled before
/// calling this function.
///
/// If the stat information belongs to something other than a normal file/
/// directory (like a socket) we just return an identity change (non-files
/// can not be committed to git).
pub fn change_to_match_fs(
self,
stat: &crate::fs::Metadata,
has_symlinks: bool,
executable_bit: bool,
) -> Option<Change> {
self.change_to_match_fs_with_values(
stat.is_file(),
stat.is_dir(),
stat.is_symlink(),
stat.is_executable(),
has_symlinks,
executable_bit,
)
}
/// Like [`change_to_match_fs`](Self::change_to_match_fs) but accepts pre-extracted
/// file-type and permission bits, for callers that already have them.
///
/// Note that some parameters are mutually exclusive, but inconsistency won't be a problem
/// if the source of the data is consistent.
///
/// * `is_file` is `true` if the file-system entry is a regular file.
/// * `is_dir` is `true` if the file-system entry is a directory.
/// * `is_symlink` is `true` if the file-system entry is a symbolic link.
/// * `is_executable` is `true` if the file-system entry has executable permissions.
///
/// These parameters match [`change_to_match_fs`](Self::change_to_match_fs).
///
/// * `has_symlinks` is `true` if the file system represents symbolic links faithfully.
/// * `executable_bit` is `true` if the file system represents executable permissions faithfully.
pub fn change_to_match_fs_with_values(
self,
is_file: bool,
is_dir: bool,
is_symlink: bool,
is_executable: bool,
has_symlinks: bool,
executable_bit: bool,
) -> Option<Change> {
match self {
Mode::FILE if !is_file => (),
Mode::SYMLINK if is_symlink => return None,
Mode::SYMLINK if has_symlinks && !is_symlink => (),
Mode::SYMLINK if !has_symlinks && !is_file => (),
Mode::COMMIT | Mode::DIR if !is_dir => (),
Mode::FILE if executable_bit && is_executable => return Some(Change::ExecutableBit),
Mode::FILE_EXECUTABLE if executable_bit && !is_executable => return Some(Change::ExecutableBit),
_ => return None,
}
let new_mode = if is_dir {
Mode::COMMIT
} else if executable_bit && is_executable {
Mode::FILE_EXECUTABLE
} else if has_symlinks && is_symlink {
Mode::SYMLINK
} else {
Mode::FILE
};
Some(Change::Type { new_mode })
}
}
impl From<gix_object::tree::EntryMode> for Mode {
fn from(value: gix_object::tree::EntryMode) -> Self {
let value: u16 = value.value();
Self::from_bits_truncate(u32::from(value))
}
}
/// A change of a [`Mode`].
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum Change {
/// The type of mode changed, like symlink => file.
Type {
/// The mode representing the new index type.
new_mode: Mode,
},
/// The executable permission of this file has changed.
ExecutableBit,
}
impl Change {
/// Applies this change to `mode` and returns the changed one.
pub fn apply(self, mode: Mode) -> Mode {
match self {
Change::Type { new_mode } => new_mode,
Change::ExecutableBit => match mode {
Mode::FILE => Mode::FILE_EXECUTABLE,
Mode::FILE_EXECUTABLE => Mode::FILE,
_ => unreachable!("invalid mode change: can't flip executable bit of {mode:?}"),
},
}
}
}
+207
View File
@@ -0,0 +1,207 @@
use std::{
cmp::Ordering,
time::{SystemTime, SystemTimeError},
};
use filetime::FileTime;
use crate::entry::Stat;
impl Stat {
/// Detect whether this stat entry is racy if stored in a file index with `timestamp`.
///
/// An index entry is considered racy if it's `mtime` is larger or equal to the index `timestamp`.
/// The index `timestamp` marks the point in time before which we definitely resolved the racy git problem
/// for all index entries so any index entries that changed afterwards will need to be examined for
/// changes by actually reading the file from disk at least once.
pub fn is_racy(
&self,
timestamp: FileTime,
Options {
check_stat, use_nsec, ..
}: Options,
) -> bool {
match timestamp.unix_seconds().cmp(&i64::from(self.mtime.secs)) {
Ordering::Less => true,
Ordering::Equal if use_nsec && check_stat => timestamp.nanoseconds() <= self.mtime.nsecs,
Ordering::Equal => true,
Ordering::Greater => false,
}
}
/// Compares the stat information of two index entries.
///
/// Intuitively this is basically equivalent to `self == other`.
/// However there a lot of nobs in git that tweak whether certain stat information is used when checking
/// equality, see [`Options`].
/// This function respects those options while performing the stat comparison and may therefore ignore some fields.
pub fn matches(
&self,
other: &Self,
Options {
trust_ctime,
check_stat,
use_nsec,
use_stdev,
}: Options,
) -> bool {
if self.mtime.secs != other.mtime.secs {
return false;
}
if check_stat && use_nsec && self.mtime.nsecs != other.mtime.nsecs {
return false;
}
if self.size != other.size {
return false;
}
if trust_ctime {
if self.ctime.secs != other.ctime.secs {
return false;
}
if check_stat && use_nsec && self.ctime.nsecs != other.ctime.nsecs {
return false;
}
}
if check_stat {
if use_stdev && self.dev != other.dev {
return false;
}
self.ino == other.ino && self.gid == other.gid && self.uid == other.uid
} else {
true
}
}
/// Creates stat information from file metadata.
///
/// The information passed to this function should originate from a function like
/// `symlink_metadata`/`lstat` or `File::metadata`/`fstat`.
///
/// The data are adjusted for use in the index, using default values of fields that are not
/// meaningful on the target operating system or that are unavailable, and truncating data
/// where doing so does not lose essential information for keeping track of file status.
pub fn from_fs(stat: &crate::fs::Metadata) -> Result<Stat, SystemTimeError> {
let mtime = stat.modified().unwrap_or(std::time::UNIX_EPOCH);
let ctime = stat.created().unwrap_or(std::time::UNIX_EPOCH);
#[cfg(windows)]
let res = Stat {
mtime: mtime.try_into()?,
ctime: ctime.try_into()?,
dev: 0,
ino: 0,
uid: 0,
gid: 0,
// Truncation to 32 bits is on purpose (git does the same).
size: stat.len() as u32,
};
#[cfg(not(windows))]
let res = {
Stat {
mtime: mtime.try_into().unwrap_or_default(),
ctime: ctime.try_into().unwrap_or_default(),
// Truncating the device and inode numbers to 32 bits should be fine even on
// targets where they are represented as 64 bits, since we do not use them
// precisely for tracking changes and we do not map them back to the inode.
dev: stat.dev() as u32,
ino: stat.ino() as u32,
uid: stat.uid(),
gid: stat.gid(),
// Truncation to 32 bits is on purpose (git does the same).
size: stat.len() as u32,
}
};
Ok(res)
}
}
impl TryFrom<SystemTime> for Time {
type Error = SystemTimeError;
fn try_from(s: SystemTime) -> Result<Self, SystemTimeError> {
let d = s.duration_since(std::time::UNIX_EPOCH)?;
Ok(Time {
// truncation to 32 bits is on purpose (we only compare the low bits)
secs: d.as_secs() as u32,
nsecs: d.subsec_nanos(),
})
}
}
impl From<Time> for SystemTime {
fn from(s: Time) -> Self {
std::time::UNIX_EPOCH + std::time::Duration::new(s.secs.into(), s.nsecs)
}
}
/// The time component in a [`Stat`] struct.
#[derive(Debug, Default, PartialEq, Eq, Hash, Ord, PartialOrd, Clone, Copy)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Time {
/// The amount of seconds elapsed since EPOCH.
pub secs: u32,
/// The amount of nanoseconds elapsed in the current second, ranging from 0 to 999.999.999 .
pub nsecs: u32,
}
impl From<FileTime> for Time {
fn from(value: FileTime) -> Self {
Time {
secs: value.unix_seconds().try_into().expect("can't represent non-unix times"),
nsecs: value.nanoseconds(),
}
}
}
impl PartialEq<FileTime> for Time {
fn eq(&self, other: &FileTime) -> bool {
*self == Time::from(*other)
}
}
impl PartialOrd<FileTime> for Time {
fn partial_cmp(&self, other: &FileTime) -> Option<Ordering> {
self.partial_cmp(&Time::from(*other))
}
}
/// Configuration for comparing stat entries
#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone)]
pub struct Options {
/// If true, a files creation time is taken into consideration when checking if a file changed.
/// Can be set to false in case other tools alter the creation time in ways that interfere with our operation.
///
/// Default `true`.
pub trust_ctime: bool,
/// If true, all stat fields will be used when checking for up-to-date'ness of the entry. Otherwise
/// nano-second parts of mtime and ctime,uid, gid, inode and device number _will not_ be used, leaving only
/// the whole-second part of ctime and mtime and the file size to be checked.
///
/// Default `true`.
pub check_stat: bool,
/// Whether to compare nano secs when comparing timestamps. This currently
/// leads to many false positives on linux and is therefore disabled there.
///
/// Default `false`
pub use_nsec: bool,
/// Whether to compare network devices secs when comparing timestamps.
/// Disabled by default because this can cause many false positives on network
/// devices where the device number is not stable
///
/// Default `false`.
pub use_stdev: bool,
}
impl Default for Options {
fn default() -> Self {
Self {
trust_ctime: true,
check_stat: true,
use_nsec: false,
use_stdev: false,
}
}
}
+37
View File
@@ -0,0 +1,37 @@
use crate::{Entry, State, entry};
impl Entry {
/// Serialize ourselves to `out` with path access via `state`, without padding.
pub fn write_to(&self, mut out: impl std::io::Write, state: &State) -> std::io::Result<()> {
let stat = self.stat;
out.write_all(&stat.ctime.secs.to_be_bytes())?;
out.write_all(&stat.ctime.nsecs.to_be_bytes())?;
out.write_all(&stat.mtime.secs.to_be_bytes())?;
out.write_all(&stat.mtime.nsecs.to_be_bytes())?;
out.write_all(&stat.dev.to_be_bytes())?;
out.write_all(&stat.ino.to_be_bytes())?;
out.write_all(&self.mode.bits().to_be_bytes())?;
out.write_all(&stat.uid.to_be_bytes())?;
out.write_all(&stat.gid.to_be_bytes())?;
out.write_all(&stat.size.to_be_bytes())?;
out.write_all(self.id.as_bytes())?;
let path = self.path(state);
let path_len: u16 = if path.len() >= entry::Flags::PATH_LEN.bits() as usize {
entry::Flags::PATH_LEN.bits() as u16
} else {
path.len()
.try_into()
.expect("we just checked that the length is smaller than 0xfff")
};
out.write_all(&(self.flags.to_storage().bits() | path_len).to_be_bytes())?;
if self.flags.contains(entry::Flags::EXTENDED) {
out.write_all(
&entry::at_rest::FlagsExtended::from_flags(self.flags)
.bits()
.to_be_bytes(),
)?;
}
out.write_all(path)?;
out.write_all(b"\0")
}
}
+82
View File
@@ -0,0 +1,82 @@
use crate::{extension, extension::Signature, util::from_be_u32};
use gix_error::{Result, bail};
pub(crate) fn header(data: &[u8]) -> (Signature, u32, &[u8]) {
let (signature, data) = data.split_at(4);
let (size, data) = data.split_at(4);
(signature.try_into().unwrap(), from_be_u32(size), data)
}
pub(crate) fn all(
maybe_beginning_of_extensions: &[u8],
object_hash: gix_hash::Kind,
alloc_limit_bytes: Option<usize>,
) -> Result<(Outcome, &[u8])> {
use gix_error::{ResultExt, message};
let mut ext_iter = match extension::Iter::new_without_checksum(maybe_beginning_of_extensions, object_hash) {
Some(iter) => iter,
None => return Ok((Outcome::default(), maybe_beginning_of_extensions)),
};
let mut ext = Outcome::default();
for (signature, ext_data) in ext_iter.by_ref() {
match signature {
extension::tree::SIGNATURE => {
ext.tree = extension::tree::decode(ext_data, object_hash, alloc_limit_bytes);
}
extension::resolve_undo::SIGNATURE => {
ext.resolve_undo = extension::resolve_undo::decode(ext_data, object_hash);
}
extension::untracked_cache::SIGNATURE => {
ext.untracked = extension::untracked_cache::decode(ext_data, object_hash, alloc_limit_bytes);
}
extension::fs_monitor::SIGNATURE => {
ext.fs_monitor = extension::fs_monitor::decode(ext_data);
}
extension::end_of_index_entry::SIGNATURE => {
ext.end_of_index = true;
} // skip already done
extension::index_entry_offset_table::SIGNATURE => {
ext.offset_table = true;
} // not relevant/obtained already
mandatory if mandatory[0].is_ascii_lowercase() => match mandatory {
extension::link::SIGNATURE => {
ext.link = extension::link::decode(ext_data, object_hash)
.or_raise(|| message("Could not parse mandatory link extension"))?
.into();
}
extension::sparse::SIGNATURE => {
if !ext_data.is_empty() {
// only used as a marker, if this changes we need this implementation.
bail!(
"Encountered mandatory extension '{}' which isn't implemented yet",
String::from_utf8_lossy(&mandatory)
);
}
ext.is_sparse = true;
}
unknown => {
bail!(
"Encountered mandatory extension '{}' which isn't implemented yet",
String::from_utf8_lossy(&unknown)
);
}
},
_unknown => {} // skip unknown extensions, too
}
}
Ok((ext, &maybe_beginning_of_extensions[ext_iter.consumed..]))
}
#[derive(Default)]
pub(crate) struct Outcome {
pub tree: Option<extension::Tree>,
pub link: Option<extension::Link>,
pub resolve_undo: Option<extension::resolve_undo::Paths>,
pub untracked: Option<extension::UntrackedCache>,
pub fs_monitor: Option<extension::FsMonitor>,
pub is_sparse: bool,
pub offset_table: bool,
pub end_of_index: bool,
}
@@ -0,0 +1,57 @@
use crate::{
decode::header,
extension,
extension::end_of_index_entry::{MIN_SIZE, MIN_SIZE_WITH_HEADER, SIGNATURE},
util::from_be_u32,
};
use gix_error::Result;
/// Decode the end of index entry extension, which is no more than a glorified offset to the first byte of all extensions to allow
/// loading entries and extensions in parallel.
///
/// Itself it's located at the end of the index file, which allows its location to be known and thus addressable.
/// From there it's possible to traverse the chunks of all set extensions, hash them, and compare that hash with all extensions
/// stored prior to this one to assure they are correct.
///
/// If the checksum wasn't matched, we will ignore this extension entirely.
pub fn decode(data: &[u8], object_hash: gix_hash::Kind) -> Result<Option<usize>> {
let hash_len = object_hash.len_in_bytes();
if data.len() < MIN_SIZE_WITH_HEADER + hash_len {
return Ok(None);
}
let start_of_eoie = data.len() - MIN_SIZE_WITH_HEADER - hash_len;
let ext_data = &data[start_of_eoie..data.len() - hash_len];
let (signature, ext_size, ext_data) = extension::decode::header(ext_data);
if signature != SIGNATURE || ext_size as usize != MIN_SIZE {
return Ok(None);
}
let (offset, checksum) = ext_data.split_at(4);
let Ok(checksum) = gix_hash::oid::try_from_bytes(checksum) else {
return Ok(None);
};
let offset = from_be_u32(offset) as usize;
if offset < header::SIZE || offset > start_of_eoie || checksum.kind() != object_hash {
return Ok(None);
}
let mut hasher = gix_hash::hasher(object_hash);
let mut last_chunk = None;
for (signature, chunk) in extension::Iter::new(&data[offset..data.len() - MIN_SIZE_WITH_HEADER - hash_len]) {
hasher.update(&signature);
hasher.update(&(chunk.len() as u32).to_be_bytes());
last_chunk = Some(chunk);
}
if hasher.try_finalize()?.verify(checksum).is_err() {
return Ok(None);
}
// The last-to-this chunk ends where ours starts
if last_chunk.is_none_or(|s| !std::ptr::eq(s.as_ptr_range().end, &data[start_of_eoie])) {
return Ok(None);
}
Ok(Some(offset))
}
@@ -0,0 +1,14 @@
use crate::{extension, extension::Signature};
/// The signature of the end-of-index-entry extension
pub const SIGNATURE: Signature = *b"EOIE";
/// The minimal size of the extension, depending on the shortest hash.
pub const MIN_SIZE: usize = 4 /* offset to extensions */ + gix_hash::Kind::shortest().len_in_bytes();
/// The smallest size of the extension varying by hash kind, along with the standard extension header.
pub const MIN_SIZE_WITH_HEADER: usize = extension::MIN_SIZE + MIN_SIZE;
mod decode;
pub use decode::decode;
mod write;
pub use write::write_to;
@@ -0,0 +1,33 @@
use crate::extension::{Signature, end_of_index_entry::SIGNATURE};
use gix_error::Result;
/// Write this extension to out and generate a hash of `object_hash` over all `prior_extensions` which are specified as `(signature, size)`
/// pair. `one_past_entries` is the offset to the first byte past the entries, which is also the first byte of the signature of the
/// first extension in `prior_extensions`. Note that `prior_extensions` must have been written prior to this one, as the name suggests,
/// allowing this extension to be the last one in the index file.
///
/// Even if there are no `prior_extensions`, this extension will be written unconditionally.
pub fn write_to(
mut out: impl std::io::Write,
object_hash: gix_hash::Kind,
offset_to_extensions: u32,
prior_extensions: impl IntoIterator<Item = (Signature, u32)>,
) -> Result {
out.write_all(&SIGNATURE).map_err(gix_hash::io::from_std_io)?;
let extension_size: u32 = 4 + object_hash.len_in_bytes() as u32;
out.write_all(&extension_size.to_be_bytes())
.map_err(gix_hash::io::from_std_io)?;
out.write_all(&offset_to_extensions.to_be_bytes())
.map_err(gix_hash::io::from_std_io)?;
let mut hasher = gix_hash::hasher(object_hash);
for (signature, size) in prior_extensions {
hasher.update(&signature);
hasher.update(&size.to_be_bytes());
}
out.write_all(hasher.try_finalize()?.as_slice())
.map_err(gix_hash::io::from_std_io)?;
Ok(())
}
@@ -0,0 +1,51 @@
use bstr::BString;
use crate::{
extension::{FsMonitor, Signature},
util::{read_u32, read_u64, split_at_byte_exclusive},
};
#[derive(Clone)]
#[expect(
dead_code,
reason = "for the time when we actually support the git status daemon, this just models its data."
)]
pub enum Token {
V1 { nanos_since_1970: u64 },
V2 { token: BString },
}
pub const SIGNATURE: Signature = *b"FSMN";
pub fn decode(data: &[u8]) -> Option<FsMonitor> {
let (version, data) = read_u32(data)?;
let (token, data) = match version {
1 => {
let (nanos_since_1970, data) = read_u64(data)?;
(Token::V1 { nanos_since_1970 }, data)
}
2 => {
let (token, data) = split_at_byte_exclusive(data, 0)?;
(Token::V2 { token: token.into() }, data)
}
_ => return None,
};
let (ewah_size, data) = read_u32(data)?;
let ((entry_dirty, extra), data) = data
.split_at_checked(ewah_size as usize)
.and_then(|(entry_dirty, data)| {
gix_bitmap::ewah::decode(entry_dirty)
.ok()
.map(|entry_dirty| (entry_dirty, data))
})?;
if !extra.is_empty() {
return None;
}
if !data.is_empty() {
return None;
}
FsMonitor { token, entry_dirty }.into()
}
@@ -0,0 +1,43 @@
use crate::{extension, extension::Signature, util::read_u32};
#[derive(Debug, Clone, Copy)]
pub struct Offset {
pub from_beginning_of_file: u32,
pub num_entries: u32,
}
pub const SIGNATURE: Signature = *b"IEOT";
pub fn decode(data: &[u8]) -> Option<Vec<Offset>> {
let (version, mut data) = read_u32(data)?;
match version {
1 => {}
_unknown => return None,
}
let entry_size = 4 + 4;
let num_offsets = data.len() / entry_size;
if num_offsets == 0 || data.len() % entry_size != 0 {
return None;
}
let mut out = Vec::with_capacity(entry_size);
for _ in 0..num_offsets {
let (offset, chunk) = read_u32(data)?;
let (num_entries, chunk) = read_u32(chunk)?;
out.push(Offset {
from_beginning_of_file: offset,
num_entries,
});
data = chunk;
}
debug_assert!(data.is_empty());
out.into()
}
pub fn find(extensions: &[u8], object_hash: gix_hash::Kind) -> Option<Vec<Offset>> {
extension::Iter::new_without_checksum(extensions, object_hash)?
.find_map(|(sig, ext_data)| (sig == SIGNATURE).then_some(ext_data))
.and_then(decode)
}
+56
View File
@@ -0,0 +1,56 @@
use crate::{extension, extension::Iter, util::from_be_u32};
impl<'a> Iter<'a> {
/// Create a new extension iterator at the entrypoint for extensions until the end of the extensions.
pub fn new(data_at_beginning_of_extensions_and_truncated: &'a [u8]) -> Self {
Iter {
data: data_at_beginning_of_extensions_and_truncated,
consumed: 0,
}
}
/// Create a new iterator at with a data block to the end of the file, and we automatically remove the trailing
/// hash of type `object_hash`.
pub fn new_without_checksum(
data_at_beginning_of_extensions: &'a [u8],
object_hash: gix_hash::Kind,
) -> Option<Self> {
let end = data_at_beginning_of_extensions
.len()
.checked_sub(object_hash.len_in_bytes())?;
Iter {
data: &data_at_beginning_of_extensions[..end],
consumed: 0,
}
.into()
}
}
impl<'a> Iterator for Iter<'a> {
type Item = (extension::Signature, &'a [u8]);
fn next(&mut self) -> Option<Self::Item> {
if self.data.len() < 4 + 4 {
return None;
}
let (signature, data) = self.data.split_at(4);
let (size, data) = data.split_at(4);
self.data = data;
self.consumed += 4 + 4;
let size = from_be_u32(size) as usize;
match data.get(..size) {
Some(ext_data) => {
self.data = &data[size..];
self.consumed += size;
Some((signature.try_into().unwrap(), ext_data))
}
None => {
self.data = &[];
None
}
}
}
}
+158
View File
@@ -0,0 +1,158 @@
use crate::extension::{Link, Signature};
use gix_error::{Result, bail, corruption};
/// The signature of the link extension.
pub const SIGNATURE: Signature = *b"link";
/// Bitmaps to know which entries to delete or replace, even though details are still unknown.
#[derive(Clone)]
pub struct Bitmaps {
/// A bitmap to signal which entries to delete, maybe.
pub delete: gix_bitmap::ewah::Vec,
/// A bitmap to signal which entries to replace, maybe.
pub replace: gix_bitmap::ewah::Vec,
}
pub(crate) fn decode(data: &[u8], object_hash: gix_hash::Kind) -> Result<Link> {
use gix_error::{OptionExt, ResultExt};
let (id, data) = data
.split_at_checked(object_hash.len_in_bytes())
.ok_or_raise(|| corruption("link extension too short to read share index checksum"))
.map(|(id, d)| (gix_hash::ObjectId::from_bytes_or_panic(id), d))?;
if data.is_empty() {
return Ok(Link {
shared_index_checksum: id,
bitmaps: None,
});
}
let (delete, data) = gix_bitmap::ewah::decode(data).or_raise(|| corruption("delete bitmap corrupt"))?;
let (replace, data) = gix_bitmap::ewah::decode(data).or_raise(|| corruption("replace bitmap corrupt"))?;
if !data.is_empty() {
bail!(corruption("garbage trailing link extension"));
}
Ok(Link {
shared_index_checksum: id,
bitmaps: Some(Bitmaps { delete, replace }),
})
}
impl Link {
pub(crate) fn dissolve_into(
self,
split_index: &mut crate::File,
object_hash: gix_hash::Kind,
skip_hash: bool,
options: crate::decode::Options,
) -> Result {
let corrupt = |message| gix_error::message(message).corrupted_error();
let shared_index_path = split_index
.path
.parent()
.expect("split index file in .git folder")
.join(format!("sharedindex.{}", self.shared_index_checksum));
let mut shared_index = crate::File::at(
shared_index_path,
object_hash,
skip_hash,
crate::decode::Options {
expected_checksum: self.shared_index_checksum.into(),
..options
},
)?;
if let Some(bitmaps) = self.bitmaps {
let mut split_entry_index = 0;
let mut err = None;
if bitmaps.replace.for_each_set_bit(|replace_index| {
let shared_entry = match shared_index.entries.get_mut(replace_index) {
Some(e) => e,
None => {
err = Some(corrupt("replace bitmap length exceeds shared index length - more entries in bitmap than found in shared index"));
return None
}
};
if shared_entry.flags.contains(crate::entry::Flags::REMOVE) {
err = Some(corrupt("entry is marked as both replace and delete"));
return None
}
let split_entry = match split_index.entries.get(split_entry_index) {
Some(e) => e,
None => {
err = Some(corrupt("replace bitmap length exceeds split index length - more entries in bitmap than found in split index"));
return None
}
};
if !split_entry.path.is_empty() {
err = Some(corrupt("paths in split index entries that are for replacement should be empty"));
return None
}
if shared_entry.path.is_empty() {
err = Some(corrupt("paths in shared index entries that are replaced should not be empty"));
return None
}
shared_entry.stat = split_entry.stat;
shared_entry.id = split_entry.id;
shared_entry.flags = split_entry.flags;
shared_entry.mode = split_entry.mode;
split_entry_index += 1;
Some(())
}).is_none() && err.is_none() {
err = Some(corrupt("replace bitmap is malformed"));
}
if let Some(err) = err {
return Err(err);
}
let split_index_path_backing = std::mem::take(&mut split_index.path_backing);
for mut split_entry in split_index.entries.drain(split_entry_index..) {
let start = shared_index.path_backing.len();
let split_index_path = split_entry.path.clone();
split_entry.path = start..start + split_entry.path.len();
shared_index.entries.push(split_entry);
shared_index
.path_backing
.extend_from_slice(&split_index_path_backing[split_index_path]);
}
if bitmaps.delete.for_each_set_bit(|delete_index| {
let shared_entry = match shared_index.entries.get_mut(delete_index) {
Some(e) => e,
None => {
err = Some(corrupt("delete bitmap length exceeds shared index length - more entries in bitmap than found in shared index"));
return None
}
};
shared_entry.flags.insert(crate::entry::Flags::REMOVE);
Some(())
}).is_none() && err.is_none() {
err = Some(corrupt("delete bitmap is malformed"));
}
if let Some(err) = err {
return Err(err);
}
shared_index
.entries
.retain(|e| !e.flags.contains(crate::entry::Flags::REMOVE));
let mut shared_entries = std::mem::take(&mut shared_index.entries);
shared_entries.sort_by(|a, b| a.cmp(b, &shared_index.state));
split_index.entries = shared_entries;
split_index.path_backing = std::mem::take(&mut shared_index.path_backing);
}
Ok(())
}
}
+100
View File
@@ -0,0 +1,100 @@
use bstr::BString;
use smallvec::SmallVec;
/// The size of the smallest possible extension, which is no more than a signature and a 0 indicating its size.
pub const MIN_SIZE: usize = 4 /* signature */ + 4 /* size */;
/// The kind of index extension.
pub type Signature = [u8; 4];
/// An iterator over the data of index extensions.
pub struct Iter<'a> {
data: &'a [u8],
/// The amount of consumed bytes as seen from our internal data pointer. Useful to continue where the iterator left off.
pub consumed: usize,
}
/// A structure to associate object ids of a tree with sections in the index entries list.
///
/// It allows to more quickly build trees by avoiding as it can quickly reuse portions of the index and its associated tree ids
/// if there was no change to them. Portions of this tree are invalidated as the index is changed.
#[derive(PartialEq, Eq, Clone, Debug)]
pub struct Tree {
/// The name of the tree/directory, or empty if it's the root tree.
pub name: SmallVec<[u8; 23]>,
/// The id of the directory tree of the associated tree object.
pub id: gix_hash::ObjectId,
/// The amount of non-tree items in this directory tree, including sub-trees, recursively.
/// The value of the top-level tree is thus equal to the value of the total amount of entries.
/// If `None`, the tree is considered invalid and needs to be refreshed
///
/// Note that this value can't be trusted, and it may exceed the amount of entries or mismatch.
pub num_entries: Option<u32>,
/// The child-trees below the current tree.
pub children: Vec<Tree>,
}
/// The link extension to track a shared index.
#[derive(Clone)]
pub struct Link {
/// The checksum of the shared index as last seen.
pub shared_index_checksum: gix_hash::ObjectId,
/// Bitmaps to tell us which entries to delete or replace.
pub bitmaps: Option<link::Bitmaps>,
}
/// The extension for untracked files.
#[derive(Debug, Clone)]
pub struct UntrackedCache {
/// Something identifying the location and machine that this cache is for.
/// Should the repository be copied to a different machine, the entire cache can immediately be invalidated.
identifier: BString,
/// Stat for the .git/info/exclude file
info_exclude: Option<untracked_cache::OidStat>,
/// Stat for the `core.excludesfile`
excludes_file: Option<untracked_cache::OidStat>,
/// Usually `.gitignore`
exclude_filename_per_dir: BString,
dir_flags: u32,
/// A list of directories and sub-directories, with `directories[0]` being the root.
directories: Vec<untracked_cache::Directory>,
}
/// The extension for keeping state on recent information provided by the filesystem monitor.
#[expect(
dead_code,
reason = "for the time when we know what to do with it, for now we just ser-de it"
)]
#[derive(Clone)]
pub struct FsMonitor {
token: fs_monitor::Token,
/// if a bit is true, the respective entry is NOT valid as per the fs monitor.
entry_dirty: gix_bitmap::ewah::Vec,
}
mod iter;
pub(crate) mod fs_monitor;
///
pub mod decode;
///
pub mod tree;
///
pub mod end_of_index_entry;
pub(crate) mod index_entry_offset_table;
///
pub mod link;
pub(crate) mod resolve_undo;
///
pub mod untracked_cache;
///
pub mod sparse;
@@ -0,0 +1,67 @@
use bstr::BString;
use gix_hash::ObjectId;
use crate::{extension::Signature, util::split_at_byte_exclusive};
pub type Paths = Vec<ResolvePath>;
#[expect(
dead_code,
reason = "REUC is decoded but not yet exposed or written; retain its fields for planned round-trip support"
)]
#[derive(Clone)]
pub struct ResolvePath {
/// relative to the root of the repository, or what would be stored in the index
name: BString,
/// 0 = ancestor/common, 1 = ours, 2 = theirs
stages: [Option<Stage>; 3],
}
#[expect(
dead_code,
reason = "REUC is decoded but not yet exposed or written; retain its fields for planned round-trip support"
)]
#[derive(Clone, Copy)]
pub struct Stage {
mode: u32,
id: ObjectId,
}
pub const SIGNATURE: Signature = *b"REUC";
pub fn decode(mut data: &[u8], object_hash: gix_hash::Kind) -> Option<Paths> {
let hash_len = object_hash.len_in_bytes();
let mut out = Vec::new();
while !data.is_empty() {
let (path, rest) = split_at_byte_exclusive(data, 0)?;
data = rest;
let mut modes = [0u32; 3];
for mode in &mut modes {
let (mode_ascii, rest) = split_at_byte_exclusive(data, 0)?;
data = rest;
*mode = u32::from_str_radix(std::str::from_utf8(mode_ascii).ok()?, 8).ok()?;
}
let mut stages = [None, None, None];
for (mode, stage) in modes.iter().zip(stages.iter_mut()) {
if *mode == 0 {
continue;
}
let (hash, rest) = data.split_at_checked(hash_len)?;
data = rest;
*stage = Some(Stage {
mode: *mode,
id: ObjectId::from_bytes_or_panic(hash),
});
}
out.push(ResolvePath {
name: path.into(),
stages,
});
}
out.into()
}
+11
View File
@@ -0,0 +1,11 @@
use crate::extension::Signature;
/// The signature of the sparse index extension, nothing more than an indicator at this time.
pub const SIGNATURE: Signature = *b"sdir";
/// Serialize the sparse index extension to `out`
pub fn write_to(mut out: impl std::io::Write) -> Result<(), std::io::Error> {
out.write_all(&SIGNATURE)?;
out.write_all(&0_u32.to_be_bytes())?;
Ok(())
}
@@ -0,0 +1,57 @@
use gix_hash::ObjectId;
use crate::{extension::Tree, util::split_at_byte_exclusive};
/// A recursive data structure
pub fn decode(data: &[u8], object_hash: gix_hash::Kind, alloc_limit_bytes: Option<usize>) -> Option<Tree> {
let (tree, data) = one_recursive(data, object_hash.len_in_bytes(), alloc_limit_bytes)?;
data.is_empty().then_some(tree)
}
fn one_recursive(data: &[u8], hash_len: usize, alloc_limit_bytes: Option<usize>) -> Option<(Tree, &[u8])> {
let (path, data) = split_at_byte_exclusive(data, 0)?;
let (entry_count, data) = split_at_byte_exclusive(data, b' ')?;
let num_entries: i32 = gix_utils::btoi::to_signed(entry_count).ok()?;
let (subtree_count, data) = split_at_byte_exclusive(data, b'\n')?;
let subtree_count: usize = gix_utils::btoi::to_unsigned(subtree_count).ok()?;
if alloc_limit_bytes.is_some_and(|limit| subtree_count.saturating_mul(std::mem::size_of::<Tree>()) > limit) {
return None;
}
let (id, mut data) = if num_entries >= 0 {
let (hash, data) = data.split_at_checked(hash_len)?;
(ObjectId::from_bytes_or_panic(hash), data)
} else {
(
ObjectId::null(gix_hash::Kind::from_hex_len(hash_len * 2).expect("valid hex_len")),
data,
)
};
let mut subtrees = Vec::new();
subtrees.try_reserve(subtree_count).ok()?;
for _ in 0..subtree_count {
let (tree, rest) = one_recursive(data, hash_len, alloc_limit_bytes)?;
subtrees.push(tree);
data = rest;
}
subtrees.sort_by(|a, b| a.name.cmp(&b.name));
let num_trees = subtrees.len();
subtrees.dedup_by(|a, b| a.name == b.name);
if num_trees != subtrees.len() {
return None;
}
Some((
Tree {
id,
num_entries: num_entries.try_into().ok(),
name: path.into(),
children: subtrees,
},
data,
))
}
+29
View File
@@ -0,0 +1,29 @@
use crate::extension::Signature;
/// The signature for tree extensions
pub const SIGNATURE: Signature = *b"TREE";
///
pub mod verify;
mod decode;
pub use decode::decode;
mod write;
#[cfg(test)]
mod tests {
use gix_testtools::size_ok;
#[test]
fn size_of_tree() {
let actual = std::mem::size_of::<crate::extension::Tree>();
let sha1 = 88;
let sha256_extra = 16;
let expected = sha1 + sha256_extra;
assert!(
size_ok(actual, expected),
"the size of this structure should not change unexpectedly: {actual} <~ {expected}"
);
}
}
@@ -0,0 +1,171 @@
use gix_error::{Result, corruption};
use std::cmp::Ordering;
use bstr::ByteSlice;
use gix_error::{OptionExt, ResultExt, bail};
use gix_object::FindExt;
use crate::extension::Tree;
impl Tree {
/// Validate the correctness of this instance. If `use_objects` is true, then `objects` will be used to access all objects.
pub fn verify(&self, use_objects: bool, objects: impl gix_object::Find) -> Result {
fn verify_recursive(
parent_id: gix_hash::ObjectId,
children: &[Tree],
mut object_buf: Option<&mut Vec<u8>>,
objects: &impl gix_object::Find,
) -> Result<Option<u32>> {
if children.is_empty() {
return Ok(None);
}
let mut entries = 0u32;
let mut prev = None::<&Tree>;
for child in children {
entries = entries
.checked_add(child.num_entries.unwrap_or(0))
.ok_or_raise(|| corruption("The combined TREE entry count exceeds the supported maximum"))?;
if let Some(prev) = prev
&& prev.name.cmp(&child.name) != Ordering::Less
{
bail!(corruption(format!(
"Parent tree '{parent_id}' contained out-of order trees prev = \"{}\" and next = \"{}\"",
prev.name.as_bstr(),
child.name.as_bstr()
)));
}
prev = Some(child);
}
if let Some(buf) = object_buf.as_mut() {
let tree_entries = objects
.find_tree_iter(&parent_id, buf)
.or_raise(|| corruption("Tree node could not be found"))?;
let mut num_entries = 0;
for entry in tree_entries {
let entry =
entry.or_raise(|| corruption(format!("Could not decode an entry in tree {parent_id}")))?;
if !entry.mode.is_tree() {
continue;
}
children
.binary_search_by(|e| e.name.as_bstr().cmp(entry.filename))
.map_err(|position| {
gix_error::message!(
"The entry {} at path \"{}\" in parent tree {parent_id} wasn't found at child position {position}, making it incomplete",
entry.oid, entry.filename
).corrupted_error()
})?;
num_entries += 1;
}
if num_entries != children.len() {
bail!(
"The tree with id {parent_id} should have {num_entries} children, but its cached representation had {} of them"
.corrupted(),
children.len()
);
}
}
for child in children {
// This is actually needed here as it's a mut ref, which isn't copy. We do a re-borrow here.
let actual_num_entries =
verify_recursive(child.id, &child.children, object_buf.as_deref_mut(), objects)?;
if let Some((actual, num_entries)) = actual_num_entries.zip(child.num_entries)
&& actual > num_entries
{
bail!(
"Expected not more than {num_entries} entries to be reachable from the top-level, but actual count was {actual}"
.corrupted()
);
}
}
Ok(entries.into())
}
let _span = gix_trace::coarse!("gix_index::extension::Tree::verify()");
if !self.name.is_empty() {
bail!(corruption(format!(
"The root tree was named \"{}\", even though it should be empty",
self.name.as_bstr()
)));
}
let mut buf = Vec::new();
let declared_entries = verify_recursive(self.id, &self.children, use_objects.then_some(&mut buf), &objects)?;
if let Some((actual, num_entries)) = declared_entries.zip(self.num_entries)
&& actual > num_entries
{
bail!(
"Expected not more than {num_entries} entries to be reachable from the top-level, but actual count was {actual}"
.corrupted()
);
}
Ok(())
}
/// Reject impossible cached entry counts using the total number of index entries as an upper bound.
///
/// This is a cheap heuristic: it doesn't prove each cached subtree count matches its actual path range,
/// but no TREE node can describe more entries than the entire index contains.
pub(crate) fn verify_entries_count(&self, num_index_entries: usize) -> Result {
if let Some(actual) = self.num_entries
&& actual as usize > num_index_entries
{
bail!(
"TREE entry \"{}\" declared {actual} entries, but the index only contains {num_index_entries} entries"
.corrupted(),
self.name.as_bstr()
);
}
for child in &self.children {
child.verify_entries_count(num_index_entries)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::Tree;
use gix_error::Result;
struct MalformedTree;
impl gix_object::Find for MalformedTree {
fn try_find<'a>(&self, _id: &gix_hash::oid, _buffer: &'a mut Vec<u8>) -> Result<Option<gix_object::Data<'a>>> {
Ok(Some(gix_object::Data::new(
b"40000 child\0",
gix_object::Kind::Tree,
gix_hash::Kind::Sha1,
)))
}
}
#[test]
fn malformed_object_tree_entries_are_not_ignored() {
let root_id = gix_hash::Kind::Sha1.null();
let tree = Tree {
name: Default::default(),
id: root_id,
num_entries: Some(1),
children: vec![Tree {
name: b"child".as_slice().into(),
id: root_id,
num_entries: Some(0),
children: Vec::new(),
}],
};
let err = tree.verify(true, MalformedTree).expect_err("malformed entry must fail");
insta::assert_debug_snapshot!(gix_testtools::redact_debug_snapshot(&(err), &[]), "malformed object tree entries are not ignored", @"
Could not decode an entry in tree Oid(1)
Caused by:
0: object parsing failed
");
assert!(err.is_validation());
}
}
@@ -0,0 +1,52 @@
use crate::extension::{Tree, tree};
use gix_error::{ResultExt, corruption};
impl Tree {
/// Serialize this instance to `out`.
pub fn write_to(&self, mut out: impl std::io::Write) -> Result<(), std::io::Error> {
fn tree_entry(
out: &mut impl std::io::Write,
tree: &Tree,
buf: &mut itoa::Buffer,
) -> Result<(), std::io::Error> {
let num_entries = match tree.num_entries {
Some(num_entries) => buf.format(num_entries),
None => buf.format(-1),
};
out.write_all(tree.name.as_slice())?;
out.write_all(b"\0")?;
out.write_all(num_entries.as_bytes())?;
out.write_all(b" ")?;
let num_children = buf.format(tree.children.len());
out.write_all(num_children.as_bytes())?;
out.write_all(b"\n")?;
if tree.num_entries.is_some() {
out.write_all(tree.id.as_bytes())?;
}
for child in &tree.children {
tree_entry(out, child, buf)?;
}
Ok(())
}
let signature = tree::SIGNATURE;
let mut entries = Vec::<u8>::new();
let mut num_buf = itoa::Buffer::new();
tree_entry(&mut entries, self, &mut num_buf)?;
out.write_all(&signature)?;
out.write_all(
&u32::try_from(entries.len())
.or_raise(|| corruption("tree extension exceeds 4GB"))
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?
.to_be_bytes(),
)?;
out.write_all(&entries)?;
Ok(())
}
}
@@ -0,0 +1,271 @@
use bstr::BString;
use gix_hash::ObjectId;
use crate::{
entry,
extension::{Signature, UntrackedCache},
util::{read_u32, split_at_byte_exclusive, var_int},
};
impl UntrackedCache {
/// Something identifying the location and machine that this cache is for.
pub fn identifier(&self) -> &bstr::BStr {
self.identifier.as_ref()
}
/// Stat and object id for the `.git/info/exclude` file, if available.
pub fn info_exclude(&self) -> Option<&OidStat> {
self.info_exclude.as_ref()
}
/// Stat and object id for the `core.excludesfile`, if available.
pub fn excludes_file(&self) -> Option<&OidStat> {
self.excludes_file.as_ref()
}
/// Usually `.gitignore`.
pub fn exclude_filename_per_dir(&self) -> &bstr::BStr {
self.exclude_filename_per_dir.as_ref()
}
/// The directory flags Git used while populating the cache.
pub fn dir_flags(&self) -> u32 {
self.dir_flags
}
/// A list of directories and sub-directories, with `directories[0]` being the root.
pub fn directories(&self) -> &[Directory] {
&self.directories
}
}
/// A structure to track filesystem stat information along with an object id, linking a worktree file with what's in our ODB.
#[derive(Clone, Debug)]
pub struct OidStat {
/// The file system stat information
pub stat: entry::Stat,
/// The id of the file in our ODB.
pub id: ObjectId,
}
impl OidStat {
/// The file system stat information.
pub fn stat(&self) -> &entry::Stat {
&self.stat
}
/// The id of the file in our ODB.
pub fn id(&self) -> ObjectId {
self.id
}
}
/// A directory with information about its untracked files, and its sub-directories
#[derive(Clone, Debug)]
pub struct Directory {
/// The directories name, or an empty string if this is the root directory.
pub name: BString,
/// Untracked files and directory names
pub untracked_entries: Vec<BString>,
/// indices for sub-directories similar to this one.
pub sub_directories: Vec<usize>,
/// The directories stat data, if available or valid // TODO: or is it the exclude file?
pub stat: Option<entry::Stat>,
/// The oid of a .gitignore file, if it exists
pub exclude_file_oid: Option<ObjectId>,
/// TODO: figure out what this really does
pub check_only: bool,
}
impl Directory {
/// The directory name, or an empty string if this is the root directory.
/// `/` is always used as path-separator.
pub fn name(&self) -> &bstr::BStr {
self.name.as_ref()
}
/// Untracked files and directory names.
pub fn untracked_entries(&self) -> &[BString] {
&self.untracked_entries
}
/// Indices for sub-directories similar to this one.
pub fn sub_directories(&self) -> &[usize] {
&self.sub_directories
}
/// The directory stat data, if available and valid.
pub fn stat(&self) -> Option<&entry::Stat> {
self.stat.as_ref()
}
/// The oid of a `.gitignore` file, if it exists.
pub fn exclude_file_oid(&self) -> Option<ObjectId> {
self.exclude_file_oid
}
/// Whether Git marked this directory as check-only.
pub fn check_only(&self) -> bool {
self.check_only
}
}
/// Only used as an indicator
pub const SIGNATURE: Signature = *b"UNTR";
/// Decode an untracked cache extension from `data`, assuming object hashes are of type `object_hash`.
pub fn decode(data: &[u8], object_hash: gix_hash::Kind, alloc_limit_bytes: Option<usize>) -> Option<UntrackedCache> {
if data.last().is_none_or(|b| *b != 0) {
return None;
}
let (identifier_len, data) = var_int(data)?;
let (identifier, data) = data.split_at_checked(identifier_len.try_into().ok()?)?;
// The on-disk layout matches git's `ondisk_untracked_cache` struct
// https://github.com/git/git/blob/2855562ca6a9c6b0e7bc780b050c1e83c9fcfbd0/dir.c#L3582-L3586
// https://github.com/git/git/blob/2855562ca6a9c6b0e7bc780b050c1e83c9fcfbd0/dir.c#L3668-L3722
// info_exclude_stat (36 bytes)
// excludes_file_stat (36 bytes)
// dir_flags ( 4 bytes)
// info_exclude hash (hash_len bytes)
// excludes_file hash (hash_len bytes)
// exclude_per_dir (NUL-terminated)
let hash_len = object_hash.len_in_bytes();
let (info_exclude_stat, data) = crate::decode::stat(data)?;
let (excludes_file_stat, data) = crate::decode::stat(data)?;
let (dir_flags, data) = read_u32(data)?;
let (info_exclude_hash, data) = data.split_at_checked(hash_len)?;
let (excludes_file_hash, data) = data.split_at_checked(hash_len)?;
let info_exclude = OidStat {
stat: info_exclude_stat,
id: ObjectId::from_bytes_or_panic(info_exclude_hash),
};
let excludes_file = OidStat {
stat: excludes_file_stat,
id: ObjectId::from_bytes_or_panic(excludes_file_hash),
};
let (exclude_filename_per_dir, data) = split_at_byte_exclusive(data, 0)?;
let (num_directory_blocks, data) = var_int(data)?;
let mut res = UntrackedCache {
identifier: identifier.into(),
info_exclude: (!info_exclude.id.is_null()).then_some(info_exclude),
excludes_file: (!excludes_file.id.is_null()).then_some(excludes_file),
exclude_filename_per_dir: exclude_filename_per_dir.into(),
dir_flags,
directories: Vec::new(),
};
if num_directory_blocks == 0 {
return data.is_empty().then_some(res);
}
let num_directory_blocks: usize = num_directory_blocks.try_into().ok()?;
if num_directory_blocks > data.len() {
return None;
}
if alloc_limit_bytes
.is_some_and(|limit| num_directory_blocks.saturating_mul(std::mem::size_of::<Directory>()) > limit)
{
return None;
}
let directories = &mut res.directories;
directories.try_reserve(num_directory_blocks).ok()?;
let data = decode_directory_block(data, directories, alloc_limit_bytes)?;
if directories.len() != num_directory_blocks {
return None;
}
let (valid, data) = gix_bitmap::ewah::decode(data).ok()?;
let (check_only, data) = gix_bitmap::ewah::decode(data).ok()?;
let (hash_valid, mut data) = gix_bitmap::ewah::decode(data).ok()?;
if valid.num_bits() > num_directory_blocks
|| check_only.num_bits() > num_directory_blocks
|| hash_valid.num_bits() > num_directory_blocks
{
return None;
}
check_only.for_each_set_bit(|index| {
let directory = directories.get_mut(index)?;
directory.check_only = true;
Some(())
})?;
valid.for_each_set_bit(|index| {
let directory = directories.get_mut(index)?;
let (stat, rest) = crate::decode::stat(data)?;
directory.stat = stat.into();
data = rest;
Some(())
})?;
hash_valid.for_each_set_bit(|index| {
let directory = directories.get_mut(index)?;
let (hash, rest) = data.split_at_checked(hash_len)?;
data = rest;
directory.exclude_file_oid = ObjectId::from_bytes_or_panic(hash).into();
Some(())
})?;
// null-byte checked in the beginning
if data.len() != 1 {
return None;
}
res.into()
}
fn decode_directory_block<'a>(
data: &'a [u8],
directories: &mut Vec<Directory>,
alloc_limit_bytes: Option<usize>,
) -> Option<&'a [u8]> {
let (num_untracked, data) = var_int(data)?;
let (num_dirs, data) = var_int(data)?;
let (name, mut data) = split_at_byte_exclusive(data, 0)?;
// Untracked names are encoded as `name\0name\0...`, and we assume names are non-empty:
// `a\0b\0` is 4 bytes for 2 entries, so each entry needs at least 2 bytes.
let max_entries_from_remaining_data = data.len() / 2;
let num_untracked: usize = num_untracked.try_into().ok()?;
let num_dirs: usize = num_dirs.try_into().ok()?;
if num_untracked > max_entries_from_remaining_data || num_dirs > max_entries_from_remaining_data {
return None;
}
if alloc_limit_bytes.is_some_and(|limit| {
num_untracked.saturating_mul(std::mem::size_of::<BString>()) > limit
|| num_dirs.saturating_mul(std::mem::size_of::<usize>()) > limit
}) {
return None;
}
let mut untracked_entries = Vec::<BString>::new();
untracked_entries.try_reserve(num_untracked).ok()?;
for _ in 0..num_untracked {
let (name, rest) = split_at_byte_exclusive(data, 0)?;
data = rest;
untracked_entries.push(name.into());
}
let index = directories.len();
directories.push(Directory {
name: name.into(),
untracked_entries,
sub_directories: {
let mut sub_directories = Vec::new();
sub_directories.try_reserve(num_dirs).ok()?;
sub_directories
},
// the following are set later through their bitmaps
stat: None,
exclude_file_oid: None,
check_only: false,
});
for _ in 0..num_dirs {
let subdir_index = directories.len();
let rest = decode_directory_block(data, directories, alloc_limit_bytes)?;
data = rest;
directories[index].sub_directories.push(subdir_index);
}
data.into()
}
+151
View File
@@ -0,0 +1,151 @@
#![allow(unused)]
use gix_error::Result;
use std::path::{Path, PathBuf};
use gix_error::{ResultExt, message};
use crate::{File, State, decode, extension};
/// A failure to open an index file, retaining its path to distinguish primary and shared indexes.
#[derive(Debug)]
pub struct OpenError {
/// The index path that could not be opened.
pub path: PathBuf,
/// The underlying I/O error.
pub source: std::io::Error,
}
impl std::fmt::Display for OpenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Could not open index file at \"{}\"", self.path.display())
}
}
impl std::error::Error for OpenError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
/// Initialization
impl File {
/// Try to open the index file at `path` with `options`, assuming `object_hash` is used throughout the file, or create a new
/// index that merely exists in memory and is empty. `skip_hash` will increase the performance by a factor of 2, at the cost of
/// possibly not detecting corruption.
///
/// Note that the `path` will not be written if it doesn't exist. A missing shared index remains an error.
pub fn at_or_default(
path: impl Into<PathBuf>,
object_hash: gix_hash::Kind,
skip_hash: bool,
options: decode::Options,
) -> Result<Self> {
let path = path.into();
Ok(match Self::at(&path, object_hash, skip_hash, options) {
Ok(f) => f,
Err(err)
if err
.downcast_any_ref::<OpenError>()
.is_some_and(|err| err.path == path && err.source.kind() == std::io::ErrorKind::NotFound) =>
{
File::from_state(State::new(object_hash), path)
}
Err(err) => return Err(err),
})
}
/// Open an index file at `path` with `options`, assuming `object_hash` is used throughout the file. If `skip_hash` is `true`,
/// we will not get or compare the checksum of the index at all, which generally increases performance of this method by a factor
/// of 2 or more.
///
/// Note that the verification of the file hash depends on `options`, and even then it's performed after the file was read and not
/// before it is read. That way, invalid files would see a more descriptive error message as we try to parse them.
pub fn at(
path: impl Into<PathBuf>,
object_hash: gix_hash::Kind,
skip_hash: bool,
options: decode::Options,
) -> Result<Self> {
let _span = gix_trace::detail!("gix_index::File::at()");
let path = path.into();
let (data, mtime) = {
let mut file = std::fs::File::open(&path)
.map_err(|source| OpenError {
path: path.clone(),
source,
})
.or_error()?;
#[cfg(target_os = "wasi")]
let data = {
// Keep the same opened snapshot if another process atomically replaces the index.
let mut data = Vec::new();
std::io::Read::read_to_end(&mut file, &mut data)
.or_raise(|| message("An IO error occurred while reading the index"))?;
// The checksum below streams from this same handle, starting at byte zero.
std::io::Seek::rewind(&mut file)
.or_raise(|| message("An IO error occurred while rewinding the index"))?;
data
};
// SAFETY: we have to take the risk of somebody changing the file underneath. Git never writes into the same file.
#[cfg(not(target_os = "wasi"))]
#[expect(unsafe_code)]
let data = unsafe { memmap2::MmapOptions::new().map_copy_read_only(&file) }
.or_raise(|| message("An IO error occurred while opening the index"))?;
// Let the decoder report truncated files before trying to read their checksum.
if !skip_hash && data.len() >= object_hash.len_in_bytes() {
// Note that even though it's trivial to offload this into a thread, which is worth it for all but the smallest
// index files, we choose more safety here just like git does and don't even try to decode the index if the hashes
// don't match.
// Thanks to `skip_hash`, we can get performance and it's under caller control, at the cost of some safety.
let expected =
gix_hash::ObjectId::from_bytes_or_panic(&data[data.len() - object_hash.len_in_bytes()..]);
if !expected.is_null() {
let _span = gix_trace::detail!("gix::open_index::hash_index", path = ?path);
let meta = file
.metadata()
.or_raise(|| message("An IO error occurred while opening the index"))?;
let num_bytes_to_hash = meta.len() - object_hash.len_in_bytes() as u64;
gix_hash::bytes(
&mut file,
num_bytes_to_hash,
object_hash,
&mut gix_utils::progress::Discard,
&Default::default(),
)?
.verify(&expected)
.or_raise(|| message("Shared index checksum mismatch"))?;
}
}
(
data,
filetime::FileTime::from_last_modification_time(
&file
.metadata()
.or_raise(|| message("An IO error occurred while opening the index"))?,
),
)
};
let (state, checksum) = State::from_bytes(&data, mtime, object_hash, options)?;
let mut file = File { state, path, checksum };
if let Some(mut link) = file.link.take() {
link.dissolve_into(&mut file, object_hash, skip_hash, options)?;
}
Ok(file)
}
/// Consume `state` and pretend it was read from `path`, setting our checksum to `null`.
///
/// `File` instances created like that should be written to disk to set the correct checksum via `[File::write()]`.
pub fn from_state(state: State, path: impl Into<PathBuf>) -> Self {
File {
state,
path: path.into(),
checksum: None,
}
}
}
+137
View File
@@ -0,0 +1,137 @@
mod impls {
use std::ops::{Deref, DerefMut};
use crate::{File, State};
impl Deref for File {
type Target = State;
fn deref(&self) -> &Self::Target {
&self.state
}
}
impl DerefMut for File {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.state
}
}
}
mod impl_ {
use std::fmt::Formatter;
use crate::{Entry, File, PathStorageRef, State};
impl std::fmt::Debug for File {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
if f.alternate() {
return f
.debug_struct("File")
.field("path", &self.path.display())
.field("checksum", &self.checksum)
.field("object_hash", &self.state.object_hash)
.field("timestamp", &self.state.timestamp)
.field("version", &self.state.version)
.field(
"entries",
&EntriesDebug {
entries: &self.state.entries,
path_backing: &self.state.path_backing,
},
)
.field("path_backing_size_bytes", &self.state.path_backing.len())
.field("is_sparse", &self.state.is_sparse)
.field("end_of_index_at_decode_time", &self.state.end_of_index_at_decode_time)
.field("offset_table_at_decode_time", &self.state.offset_table_at_decode_time)
.field("tree", &self.state.tree)
.field("has_link", &self.state.link.is_some())
.field("has_resolve_undo", &self.state.resolve_undo.is_some())
.field("untracked", &self.state.untracked)
.field("has_fs_monitor", &self.state.fs_monitor.is_some())
.finish();
}
f.debug_struct("File")
.field("path", &self.path.display())
.field("checksum", &self.checksum)
.finish_non_exhaustive()
}
}
struct EntriesDebug<'a> {
entries: &'a [Entry],
path_backing: &'a PathStorageRef,
}
impl std::fmt::Debug for EntriesDebug<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
if !f.alternate() {
return f.debug_list().entries(self.entries).finish();
}
writeln!(f, "[")?;
for entry in self.entries {
write!(f, " ")?;
entry.fmt_debug(f, Some(self.path_backing))?;
writeln!(f, ",")?;
}
write!(f, "]")
}
}
impl From<File> for State {
fn from(f: File) -> Self {
f.state
}
}
}
mod access {
use crate::File;
/// Consumption
impl File {
/// Take all non-copy parts of the index.
pub fn into_parts(self) -> (crate::State, std::path::PathBuf) {
(self.state, self.path)
}
}
/// Access
impl File {
/// The path from which the index was read or to which it is supposed to be written when used with [`File::from_state()`].
pub fn path(&self) -> &std::path::Path {
&self.path
}
/// The checksum over the file that was read or written to disk, or `None` if the state in memory was never serialized.
///
/// Note that even if `Some`, it will only represent the state in memory right after reading or [writing][File::write()].
pub fn checksum(&self) -> Option<gix_hash::ObjectId> {
self.checksum
}
}
}
mod mutation {
use std::path::PathBuf;
use crate::File;
/// Mutating access
impl File {
/// Set the path at which we think we are located to the given `path`.
///
/// This is useful to change the location of the index *once* it is written via [`write()`][File::write()].
pub fn set_path(&mut self, path: impl Into<PathBuf>) {
self.path = path.into();
}
}
}
///
pub mod init;
///
pub mod verify;
///
pub mod write;
+33
View File
@@ -0,0 +1,33 @@
use gix_error::Result;
use std::sync::atomic::AtomicBool;
use crate::File;
impl File {
/// Verify the integrity of the index to assure its consistency.
pub fn verify_integrity(&self) -> Result {
use gix_error::{ResultExt, message};
let _span = gix_trace::coarse!("gix_index::File::verify_integrity()");
if let Some(checksum) = self.checksum {
let num_bytes_to_hash = self
.path
.metadata()
.or_raise(|| message("Could not read index file to generate hash"))?
.len()
- checksum.as_bytes().len() as u64;
let should_interrupt = AtomicBool::new(false);
gix_hash::bytes_of_file(
&self.path,
num_bytes_to_hash,
checksum.kind(),
&mut gix_utils::progress::Discard,
&should_interrupt,
)
.or_raise(|| message("Could not read index file to generate hash"))?
.verify(&checksum)
.or_raise(|| message("Index checksum mismatch"))?;
}
Ok(())
}
}
+83
View File
@@ -0,0 +1,83 @@
use crate::{File, Version, write};
use gix_error::Result;
impl File {
/// Write the index to `out` with `options`, to be readable by [`File::at()`], returning the version that was actually written
/// to retain all information of this index.
///
/// Note that the `tree` (tree-cache) extension is written as-is and is **not** recomputed or
/// invalidated to match the current entries; see [`File::write()`] for the implications and the
/// recommended workaround.
pub fn write_to(
&self,
mut out: impl std::io::Write,
options: write::Options,
) -> Result<(Version, gix_hash::ObjectId)> {
let _span = gix_trace::detail!("gix_index::File::write_to()", skip_hash = options.skip_hash);
let (version, hash) = if options.skip_hash {
let out: &mut dyn std::io::Write = &mut out;
let version = self.state.write_to(out, options)?;
(version, self.state.object_hash.null())
} else {
let mut hasher = gix_hash::io::Write::new(&mut out, self.state.object_hash);
let out: &mut dyn std::io::Write = &mut hasher;
let version = self.state.write_to(out, options)?;
(version, hasher.hash.try_finalize()?)
};
out.write_all(hash.as_slice()).map_err(gix_hash::io::from_std_io)?;
Ok((version, hash))
}
/// Write ourselves to the path we were read from after acquiring a lock, using `options`.
///
/// Note that the hash produced will be stored which is why we need to be mutable.
///
/// ### The `tree` (tree-cache) extension is written as-is
///
/// The `tree` extension (tree-cache) is serialized from its current in-memory state; it is
/// **not** recomputed or invalidated to match the entries. So if entries were modified since the
/// index was read, the tree-cache is written back still marked valid even though it is now stale.
///
/// Git uses the tree-cache to skip unchanged directories when building a tree (on `git commit` /
/// `git write-tree`), so a stale-but-valid tree-cache can make a later commit capture outdated
/// subtree content; more generally, `git status` and later commits can disagree about what is
/// staged.
///
/// Until the tree-cache is updated on write (see [issue #2421]), remove it with
/// [`State::remove_tree()`](crate::State::remove_tree()) before writing whenever entries were
/// changed:
///
/// ```ignore
/// index.remove_tree();
/// index.write(gix_index::write::Options::default())?;
/// ```
///
/// [issue #2421]: https://github.com/GitoxideLabs/gitoxide/issues/2421
pub fn write(&mut self, options: write::Options) -> Result {
use gix_error::{ErrorExt, ResultExt, bail, message};
let _span = gix_trace::detail!("gix_index::File::write()", path = ?self.path);
let mut lock = std::io::BufWriter::with_capacity(
64 * 1024,
gix_lock::File::acquire_to_update_resource(&self.path, gix_lock::acquire::Fail::Immediately, None, 0)
.or_raise(|| message("Could not acquire lock for index file"))?,
);
let (version, digest) = self
.write_to(&mut lock, options)
.or_raise(|| message("Could not write index"))?;
match lock.into_inner() {
Ok(lock) => lock
.commit()
.or_raise(|| message("Could not commit lock for index file"))?,
Err(err) => {
bail!(
err.into_error()
.and_raise(message("Could not flush buffered index data"))
);
}
};
self.state.version = version;
self.checksum = Some(digest);
Ok(())
}
}
+229
View File
@@ -0,0 +1,229 @@
//! This module contains a `Metadata` implementation that must be used instead of `std::fs::Metadata` to assure
//! that the `ctime` information is populated exactly like the one in `git`, which wouldn't be the case on unix.
#![allow(clippy::useless_conversion)] // on some MacOOS conversions are required, but on linux usually not.
#![allow(clippy::unnecessary_cast)]
// it's allowed for good measure, in case there are systems that use different types for that.
use std::{path::Path, time::SystemTime};
/// A structure to partially mirror [`std::fs::Metadata`].
#[cfg(unix)]
pub struct Metadata(rustix::fs::Stat);
#[cfg(not(unix))]
/// A structure to partially mirror [`std::fs::Metadata`].
pub struct Metadata(std::fs::Metadata);
#[cfg(windows)]
impl From<std::fs::Metadata> for Metadata {
/// Wrap existing Windows metadata without accessing the filesystem.
///
/// Callers requiring no-follow semantics must obtain the metadata accordingly,
/// for example with [`std::fs::symlink_metadata`] or [`std::fs::DirEntry::metadata`].
/// This conversion is Windows-only: on Unix, this wrapper uses inode change time
/// instead of the creation time exposed by [`std::fs::Metadata`].
fn from(metadata: std::fs::Metadata) -> Self {
Metadata(metadata)
}
}
/// Lifecycle
impl Metadata {
/// Obtain the metadata at `path` without following symlinks.
pub fn from_path_no_follow(path: &Path) -> Result<Self, std::io::Error> {
#[cfg(unix)]
{
rustix::fs::lstat(path).map(Metadata).map_err(Into::into)
}
#[cfg(not(unix))]
path.symlink_metadata().map(Metadata)
}
/// Obtain the metadata at `path` without following symlinks.
pub fn from_file(file: &std::fs::File) -> Result<Self, std::io::Error> {
#[cfg(unix)]
{
rustix::fs::fstat(file).map(Metadata).map_err(Into::into)
}
#[cfg(not(unix))]
file.metadata().map(Metadata)
}
}
/// Access
#[expect(
clippy::len_without_is_empty,
reason = "emptiness is either impossible or not meaningful for this type"
)]
impl Metadata {
/// Return true if the metadata belongs to a directory
pub fn is_dir(&self) -> bool {
#[cfg(unix)]
{
(self.0.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFDIR as u32
}
#[cfg(not(unix))]
self.0.is_dir()
}
/// Return the time at which the underlying file was modified.
pub fn modified(&self) -> Option<SystemTime> {
#[cfg(unix)]
{
#[cfg(not(any(target_os = "aix", target_os = "hurd")))]
let seconds = self.0.st_mtime;
#[cfg(any(target_os = "aix", target_os = "hurd"))]
let seconds = self.0.st_mtim.tv_sec;
#[cfg(not(any(target_os = "aix", target_os = "hurd")))]
let nanoseconds = self.0.st_mtime_nsec;
#[cfg(any(target_os = "aix", target_os = "hurd"))]
let nanoseconds = self.0.st_mtim.tv_nsec;
// All operating systems treat the seconds as offset from unix epoch, hence it must
// be signed in order to deal with dates before epoch.
// Rustix seems to think this value is u64, but we fix it here for now.
let seconds = seconds as i64;
system_time_from_secs_nanos(seconds, nanoseconds.try_into().ok()?)
}
#[cfg(not(unix))]
self.0.modified().ok()
}
/// Return the time at which the underlying file was created.
///
/// On Unix, this differs from [`std::fs::Metadata::created()`] which would return
/// the inode birth time, which is notably different to what `git` does.
pub fn created(&self) -> Option<SystemTime> {
#[cfg(unix)]
{
#[cfg(not(any(target_os = "aix", target_os = "hurd")))]
let seconds = self.0.st_ctime;
#[cfg(any(target_os = "aix", target_os = "hurd"))]
let seconds = self.0.st_ctim.tv_sec;
#[cfg(not(any(target_os = "aix", target_os = "hurd")))]
let nanoseconds = self.0.st_ctime_nsec;
#[cfg(any(target_os = "aix", target_os = "hurd"))]
let nanoseconds = self.0.st_ctim.tv_nsec;
// All operating systems treat the seconds as offset from unix epoch, hence it must
// be signed in order to deal with dates before epoch.
// Rustix seems to think this value is u64, but we fix it here for now.
let seconds = seconds as i64;
system_time_from_secs_nanos(seconds, nanoseconds.try_into().ok()?)
}
#[cfg(not(unix))]
self.0.created().ok()
}
/// Return the size of the file in bytes.
pub fn len(&self) -> u64 {
#[cfg(unix)]
{
self.0.st_size as u64
}
#[cfg(not(unix))]
self.0.len()
}
/// Return the device id on which the file is located, or 0 on windows.
pub fn dev(&self) -> u64 {
#[cfg(unix)]
{
self.0.st_dev as u64
}
#[cfg(not(unix))]
0
}
/// Return the inode id tracking the file, or 0 on windows.
pub fn ino(&self) -> u64 {
#[cfg(unix)]
{
self.0.st_ino as u64
}
#[cfg(not(unix))]
0
}
/// Return the user-id of the file or 0 on windows.
pub fn uid(&self) -> u32 {
#[cfg(unix)]
{
self.0.st_uid as u32
}
#[cfg(not(unix))]
0
}
/// Return the group-id of the file or 0 on windows.
pub fn gid(&self) -> u32 {
#[cfg(unix)]
{
self.0.st_gid as u32
}
#[cfg(not(unix))]
0
}
/// Return `true` if the file's executable bit is set, or `false` on windows.
pub fn is_executable(&self) -> bool {
#[cfg(unix)]
{
(self.0.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFREG as u32
&& self.0.st_mode as u32 & libc::S_IXUSR as u32 == libc::S_IXUSR as u32
}
#[cfg(not(unix))]
gix_fs::is_executable(&self.0)
}
/// Return `true` if the file's is a symbolic link.
pub fn is_symlink(&self) -> bool {
#[cfg(unix)]
{
(self.0.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFLNK as u32
}
#[cfg(not(unix))]
self.0.is_symlink()
}
/// Return `true` if this is a regular file, executable or not.
pub fn is_file(&self) -> bool {
#[cfg(unix)]
{
(self.0.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFREG as u32
}
#[cfg(not(unix))]
self.0.is_file()
}
}
#[cfg(unix)]
fn system_time_from_secs_nanos(secs: i64, nanos: i32) -> Option<SystemTime> {
// Copied from https://github.com/rust-lang/rust at a8ece1190bf6b340175bc5b688e52bd29924f483, MIT licensed, and adapted.
// On Apple OS, dates before epoch are represented differently than on other
// Unix platforms: e.g. 1/10th of a second before epoch is represented as `seconds=-1`
// and `nanoseconds=100_000_000` on other platforms, but is `seconds=0` and
// `nanoseconds=-900_000_000` on Apple OS.
//
// To compensate, we first detect this special case by checking if both
// seconds and nanoseconds are in range, and then correct the value for seconds
// and nanoseconds to match the common unix representation.
//
// Please note that Apple OS nonetheless accepts the standard unix format when
// setting file times, which makes this compensation round-trippable and generally
// transparent.
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos", target_os = "watchos"))]
let (secs, nanos) = if (secs <= 0 && secs > i64::MIN) && (nanos < 0 && nanos > -1_000_000_000) {
(secs - 1, nanos + 1_000_000_000)
} else {
(secs, nanos)
};
let d = std::time::Duration::new(secs.abs_diff(0), nanos.try_into().ok()?);
Some(if secs < 0 {
std::time::UNIX_EPOCH - d
} else {
std::time::UNIX_EPOCH + d
})
}
+214
View File
@@ -0,0 +1,214 @@
///
pub mod from_tree {
use gix_error::Result;
use std::collections::VecDeque;
use bstr::{BStr, BString, ByteSlice, ByteVec};
use gix_error::{ErrorExt, bail, message};
use gix_object::{tree, tree::EntryKind};
use gix_traverse::tree::{Visit, depthfirst, visit::Action};
use crate::{
Entry, PathStorage, State, Version,
entry::{Flags, Mode, Stat},
};
/// Initialization
impl State {
/// Return a new and empty in-memory index assuming the given `object_hash`.
pub fn new(object_hash: gix_hash::Kind) -> Self {
State {
object_hash,
timestamp: filetime::FileTime::now(),
version: Version::V2,
entries: vec![],
path_backing: vec![],
is_sparse: false,
tree: None,
link: None,
resolve_undo: None,
untracked: None,
fs_monitor: None,
offset_table_at_decode_time: false,
end_of_index_at_decode_time: false,
}
}
/// Create an index [`State`] by traversing `tree` recursively, accessing sub-trees
/// with `objects`.
/// `validate` is used to determine which validations to perform on every path component we see.
///
/// # Security
///
/// This currently trusts tree shape beyond individual path-component validation, and is exploitable with
/// malicious trees that contain file/directory conflicts like `a` and `a/x`. Such trees can produce an
/// index [`State`] with inconsistent paths, which may panic or confuse downstream checkout/index consumers.
///
/// A previous normalization pass tried to remove these conflicts after traversal, but only checked adjacent
/// entries. That was incomplete because [`Entry::cmp_filepaths()`] can order unrelated paths between a file
/// and its conflicting child, for example `a`, `a.`, `a/x`.
///
/// **No extension data is currently produced**.
pub fn from_tree<Find>(
tree: &gix_hash::oid,
objects: Find,
validate: gix_validate::path::component::Options,
) -> Result<Self>
where
Find: gix_object::Find,
{
let _span = gix_trace::coarse!("gix_index::State::from_tree()");
let mut delegate = CollectEntries::new(validate);
let traversal = depthfirst(tree.to_owned(), depthfirst::State::default(), &objects, &mut delegate);
if let Some((path, err)) = delegate.invalid_path.take() {
bail!(err.and_raise(message!("The path \"{path}\" is invalid").validation()));
}
traversal?;
let CollectEntries {
entries,
path_backing,
path: _,
path_deque: _,
validate: _,
invalid_path: _,
} = delegate;
Ok(State {
object_hash: tree.kind(),
timestamp: filetime::FileTime::now(),
version: Version::V2,
entries,
path_backing,
is_sparse: false,
tree: None,
link: None,
resolve_undo: None,
untracked: None,
fs_monitor: None,
offset_table_at_decode_time: false,
end_of_index_at_decode_time: false,
})
}
}
struct CollectEntries {
entries: Vec<Entry>,
path_backing: PathStorage,
path: BString,
path_deque: VecDeque<BString>,
validate: gix_validate::path::component::Options,
invalid_path: Option<(BString, gix_validate::path::component::Error)>,
}
impl CollectEntries {
pub fn new(validate: gix_validate::path::component::Options) -> CollectEntries {
CollectEntries {
entries: Vec::new(),
path_backing: Vec::new(),
path: BString::default(),
path_deque: VecDeque::new(),
validate,
invalid_path: None,
}
}
fn push_element(&mut self, name: &BStr) {
if name.is_empty() {
return;
}
if !self.path.is_empty() {
self.path.push(b'/');
}
self.path.push_str(name);
if self.invalid_path.is_none()
&& let Err(err) = gix_validate::path::component(name, None, self.validate)
{
self.invalid_path = Some((self.path.clone(), err));
}
}
pub fn add_entry(&mut self, entry: &tree::EntryRef<'_>) {
let mode = match entry.mode.kind() {
EntryKind::Tree => unreachable!("visit_non_tree() called us"),
EntryKind::Blob => Mode::FILE,
EntryKind::BlobExecutable => Mode::FILE_EXECUTABLE,
EntryKind::Link => Mode::SYMLINK,
EntryKind::Commit => Mode::COMMIT,
};
// There are leaf-names that require special validation, specific to their mode.
// Double-validate just for this case, as the previous validation didn't know the mode yet.
if self.invalid_path.is_none() {
let start = self.path.rfind_byte(b'/').map(|pos| pos + 1).unwrap_or_default();
if let Err(err) = gix_validate::path::component(
self.path[start..].as_ref(),
(entry.mode.kind() == EntryKind::Link).then_some(gix_validate::path::component::Mode::Symlink),
self.validate,
) {
self.invalid_path = Some((self.path.clone(), err));
}
}
let path_start = self.path_backing.len();
self.path_backing.extend_from_slice(&self.path);
let new_entry = Entry {
stat: Stat::default(),
id: entry.oid.into(),
flags: Flags::empty(),
mode,
path: path_start..self.path_backing.len(),
};
self.entries.push(new_entry);
}
fn determine_action(&self) -> Action {
if self.invalid_path.is_none() {
std::ops::ControlFlow::Continue(true)
} else {
std::ops::ControlFlow::Break(())
}
}
}
impl Visit for CollectEntries {
fn pop_back_tracked_path_and_set_current(&mut self) {
self.path = self.path_deque.pop_back().unwrap_or_default();
}
fn pop_front_tracked_path_and_set_current(&mut self) {
self.path = self
.path_deque
.pop_front()
.expect("every call is matched with push_tracked_path_component");
}
fn push_back_tracked_path_component(&mut self, component: &BStr) {
self.push_element(component);
self.path_deque.push_back(self.path.clone());
}
fn push_path_component(&mut self, component: &BStr) {
self.push_element(component);
}
fn pop_path_component(&mut self) {
if let Some(pos) = self.path.rfind_byte(b'/') {
self.path.resize(pos, 0);
} else {
self.path.clear();
}
}
fn visit_tree(&mut self, _entry: &gix_object::tree::EntryRef<'_>) -> Action {
self.determine_action()
}
fn visit_nontree(&mut self, entry: &gix_object::tree::EntryRef<'_>) -> Action {
self.add_entry(entry);
self.determine_action()
}
}
}
+285
View File
@@ -0,0 +1,285 @@
//! ## Feature Flags
#![cfg_attr(
all(doc, feature = "document-features"),
doc = ::document_features::document_features!()
)]
#![cfg_attr(all(doc, feature = "document-features"), feature(doc_cfg))]
#![deny(unsafe_code, missing_docs)]
use std::{ops::Range, path::PathBuf};
use bstr::{BStr, ByteSlice};
use filetime::FileTime;
/// `gix_hash` is made available as it's part of the public API in various places.
pub use gix_hash as hash;
/// A re-export to allow calling [`State::from_tree()`].
pub use gix_validate as validate;
///
pub mod file;
///
pub mod extension;
///
pub mod entry;
mod access;
///
pub mod init;
///
pub mod decode;
///
pub mod verify;
///
pub mod write;
pub mod fs;
/// All known versions of a git index file.
#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone, Copy)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Version {
/// Supports entries and various extensions.
V2 = 2,
/// Adds support for additional flags for each entry, called extended entries.
V3 = 3,
/// Supports deltified entry paths.
V4 = 4,
}
/// An entry in the index, identifying a non-tree item on disk.
#[derive(Clone, Eq, PartialEq)]
pub struct Entry {
/// The filesystem stat information for the file on disk.
pub stat: entry::Stat,
/// The object id for this entry's ODB representation (assuming it's up-to-date with it).
pub id: gix_hash::ObjectId,
/// Additional flags for use in algorithms and for efficiently storing stage information.
pub flags: entry::Flags,
/// The kind of item this entry represents - it's not all blobs in the index anymore.
pub mode: entry::Mode,
/// The range to lookup in the path backing to obtain the entry path relative to the repository.
/// This costs additional memory but is probably worth it given that paths can stay in one big allocation.
path: Range<usize>,
}
/// An index file whose state was read from a file on disk.
#[derive(Clone)]
pub struct File {
/// The state containing the actual index data.
pub(crate) state: State,
/// The path from which the index was read or to which it is supposed to be written.
pub(crate) path: PathBuf,
/// The checksum of all bytes prior to the checksum itself.
pub(crate) checksum: Option<gix_hash::ObjectId>,
}
/// The type to use and store paths to all entries.
pub type PathStorage = Vec<u8>;
/// The type to use and store paths to all entries, as reference
pub type PathStorageRef = [u8];
struct DirEntry<'a> {
/// The first entry in the directory
entry: &'a Entry,
/// One past the last byte of the directory in the path-backing
dir_end: usize,
}
impl DirEntry<'_> {
fn path<'a>(&self, state: &'a State) -> &'a BStr {
let range = self.entry.path.start..self.dir_end;
state.path_backing[range].as_bstr()
}
}
/// A backing store for accelerating lookups of entries in a case-sensitive and case-insensitive manner.
pub struct AccelerateLookup<'a> {
/// The entries themselves, hashed by their full icase path.
/// Icase-clashes are handled in order of occurrence and are all available for iteration.
icase_entries: hashbrown::HashTable<&'a Entry>,
/// Each hash in this table corresponds to a directory containing one or more entries.
icase_dirs: hashbrown::HashTable<DirEntry<'a>>,
}
/// An in-memory cache of a fully parsed git index file.
///
/// As opposed to a snapshot, it's meant to be altered and eventually be written back to disk or converted into a tree.
/// We treat index and its state synonymous.
///
/// # Path Format
///
/// All entry paths stored by [`State`], and all path-like arguments used to access entries, are repository-relative byte
/// strings with `/` as separator. They are not platform-native filesystem paths, must not be absolute, and must not use
/// separators like `\`; convert worktree or absolute paths to this representation before lookup or insertion.
///
/// # A note on safety
///
/// An index (i.e. [`State`]) created by hand is not guaranteed to have valid entry paths as they are entirely controlled
/// by the caller, without applying any level of validation.
///
/// This means that before using these paths to recreate files on disk, *they must be validated*.
///
/// It's notable that it's possible to manufacture tree objects which contain names like `.git/hooks/pre-commit`
/// which then will look like `.git/hooks/pre-commit` in the index, which doesn't care that the name came from a single
/// tree instead of from trees named `.git`, `hooks` and a blob named `pre-commit`. The effect is still the same - an invalid
/// path is presented in the index and its consumer must validate each path component before usage.
///
/// It's recommended to do that using `gix_worktree::Stack` which has it built-in if it's created `for_checkout()`. Alternatively
/// one can validate component names with `gix_validate::path::component()`.
#[derive(Clone)]
pub struct State {
/// The kind of object hash used when storing the underlying file.
///
/// Empty states for example won't have a single object id, so deduction of the hash used isn't always possible.
object_hash: gix_hash::Kind,
/// The time at which the state was created, indicating its freshness compared to other files on disk.
///
/// Note that on platforms that only have a precisions of a second for this time, we will treat all entries with the
/// same timestamp as this as potentially changed, checking more thoroughly if a change actually happened.
timestamp: FileTime,
version: Version,
entries: Vec<Entry>,
/// A memory area keeping all index paths, in full length, independently of the index version.
///
/// Ranges into this storage are referred to by parts of `entries`.
path_backing: PathStorage,
/// True if one entry in the index has a special marker mode
is_sparse: bool,
// Extensions
end_of_index_at_decode_time: bool,
offset_table_at_decode_time: bool,
tree: Option<extension::Tree>,
link: Option<extension::Link>,
resolve_undo: Option<extension::resolve_undo::Paths>,
untracked: Option<extension::UntrackedCache>,
fs_monitor: Option<extension::FsMonitor>,
}
mod impls {
use std::fmt::{Debug, Formatter};
use crate::{Entry, PathStorageRef, State, entry::Stage};
impl Entry {
pub(crate) fn fmt_debug(&self, f: &mut Formatter, path_backing: Option<&PathStorageRef>) -> std::fmt::Result {
if f.alternate() {
write!(
f,
"{} {}{:?} mtime: {:?} {} ",
match self.flags.stage() {
Stage::Unconflicted => " ",
Stage::Base => "BASE ",
Stage::Ours => "OURS ",
Stage::Theirs => "THEIRS ",
},
if self.flags.is_empty() {
"".to_string()
} else {
format!("{:?} ", self.flags)
},
self.mode,
self.stat.mtime,
self.id,
)?;
return match path_backing {
Some(path_backing) => write!(f, "{}", self.path_in(path_backing)),
None => write!(f, "{:?}", self.path),
};
}
let mut entry = f.debug_struct("Entry");
entry
.field("stat", &self.stat)
.field("id", &self.id)
.field("flags", &self.flags)
.field("mode", &self.mode);
match path_backing {
Some(path_backing) => entry.field("path", &self.path_in(path_backing)),
None => entry.field("path", &self.path),
}
.finish()
}
}
impl Debug for Entry {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
self.fmt_debug(f, None)
}
}
impl Debug for State {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
for entry in &self.entries {
writeln!(
f,
"{} {}{:?} {} {}",
match entry.flags.stage() {
Stage::Unconflicted => " ",
Stage::Base => "BASE ",
Stage::Ours => "OURS ",
Stage::Theirs => "THEIRS ",
},
if entry.flags.is_empty() {
"".to_string()
} else {
format!("{:?} ", entry.flags)
},
entry.mode,
entry.id,
entry.path(self)
)?;
}
Ok(())
}
}
}
pub(crate) mod util {
#[inline]
pub fn var_int(data: &[u8]) -> Option<(u64, &[u8])> {
let (num, consumed) = gix_utils::decode::leb64_from_read(data).ok()?;
let data = &data[consumed..];
(num, data).into()
}
#[inline]
pub fn read_u32(data: &[u8]) -> Option<(u32, &[u8])> {
data.split_at_checked(4)
.map(|(num, data)| (u32::from_be_bytes(num.try_into().unwrap()), data))
}
#[inline]
pub fn read_u64(data: &[u8]) -> Option<(u64, &[u8])> {
data.split_at_checked(8)
.map(|(num, data)| (u64::from_be_bytes(num.try_into().unwrap()), data))
}
#[inline]
pub fn from_be_u32(b: &[u8]) -> u32 {
u32::from_be_bytes(b.try_into().unwrap())
}
#[inline]
pub fn split_at_byte_exclusive(data: &[u8], byte: u8) -> Option<(&[u8], &[u8])> {
if data.len() < 2 {
return None;
}
data.iter().enumerate().find_map(|(idx, b)| {
(*b == byte).then(|| {
if idx == 0 {
(&[] as &[u8], &data[1..])
} else {
let (a, b) = data.split_at(idx);
(a, &b[1..])
}
})
})
}
}
+38
View File
@@ -0,0 +1,38 @@
use gix_error::{Result, bail};
use std::cmp::Ordering;
use crate::State;
impl State {
/// Assure our entries are consistent.
pub fn verify_entries(&self) -> Result {
let _span = gix_trace::coarse!("gix_index::File::verify_entries()");
let mut previous = None::<&crate::Entry>;
for (idx, entry) in self.entries.iter().enumerate() {
if let Some(prev) = previous
&& prev.cmp(entry, self) != Ordering::Less
{
bail!(gix_error::corruption(format!(
"Entry \"{}\" (stage = {}) at index {idx} should order after prior entry \"{}\" (stage = {})",
entry.path(self),
entry.flags.stage() as u8,
prev.path(self),
prev.flags.stage() as u8
)));
}
previous = Some(entry);
}
Ok(())
}
/// Note: `objects` cannot be `Option<F>` as we can't call it with a closure then due to the indirection through `Some`.
pub fn verify_extensions(&self, use_find: bool, objects: impl gix_object::Find) -> Result {
if let Some(tree) = self.tree() {
tree.verify(use_find, objects)?;
tree.verify_entries_count(self.entries.len())?;
}
// TODO: verify links by running the whole set of tests on the index
// - do that once we load it as well, or maybe that's lazy loaded? Too many questions for now.
Ok(())
}
}
+229
View File
@@ -0,0 +1,229 @@
use gix_error::Result;
use std::io::Write;
use crate::{State, Version, entry, extension, write::util::CountBytes};
/// A way to specify which of the optional extensions to write.
#[derive(Default, Debug, Copy, Clone)]
pub enum Extensions {
/// Writes all available optional extensions to avoid losing any information.
#[default]
All,
/// Only write the given optional extensions, with each extension being marked by a boolean flag.
///
/// # Note: mandatory extensions
///
/// Mandatory extensions, like `sdir` or other lower-case ones, may not be configured here as they need to be present
/// or absent depending on the state of the index itself and for it to be valid.
Given {
/// Write the tree-cache extension, if present.
tree_cache: bool,
/// Write the end-of-index-entry extension.
end_of_index_entry: bool,
},
/// Write no optional extension at all for what should be the smallest possible index
None,
}
impl Extensions {
/// Returns `Some(signature)` if it should be written out.
pub fn should_write(&self, signature: extension::Signature) -> Option<extension::Signature> {
match self {
Extensions::None => None,
Extensions::All => Some(signature),
Extensions::Given {
tree_cache,
end_of_index_entry,
} => match signature {
extension::tree::SIGNATURE => tree_cache,
extension::end_of_index_entry::SIGNATURE => end_of_index_entry,
_ => &false,
}
.then(|| signature),
}
}
}
/// The options for use when [writing an index][State::write_to()].
///
/// Note that default options write either index V2 or V3 depending on the content of the entries.
#[derive(Debug, Default, Clone, Copy)]
pub struct Options {
/// Configures which extensions to write.
pub extensions: Extensions,
/// Set the trailing hash of the produced index to all zeroes to save some time.
///
/// This value is typically controlled by `index.skipHash` and is respected when the index is written
/// via [`File::write()`](crate::File::write()) and [`File::write_to()`](crate::File::write_to()).
/// Note that
pub skip_hash: bool,
}
impl State {
/// Serialize this instance to `out` with [`options`][Options].
///
/// Note that the `tree` (tree-cache) extension is written as-is and is **not** recomputed or
/// invalidated to match the entries; see [`File::write()`](crate::File::write()) for the
/// implications and the recommended workaround.
pub fn write_to(
&self,
out: impl std::io::Write,
Options {
extensions,
skip_hash: _,
}: Options,
) -> Result<Version> {
let _span = gix_trace::detail!("gix_index::State::write()");
let version = self.detect_required_version();
let mut write = CountBytes::new(out);
let num_entries: u32 = self
.entries()
.len()
.try_into()
.expect("definitely not 4billion entries");
let removed_entries: u32 = self
.entries()
.iter()
.filter(|e| e.flags.contains(entry::Flags::REMOVE))
.count()
.try_into()
.expect("definitely not too many entries");
let offset_to_entries =
header(&mut write, version, num_entries - removed_entries).map_err(gix_hash::io::from_std_io)?;
let offset_to_extensions = entries(&mut write, self, offset_to_entries).map_err(gix_hash::io::from_std_io)?;
let (extension_toc, out) = self
.write_extensions(write, offset_to_extensions, extensions)
.map_err(gix_hash::io::from_std_io)?;
if num_entries > 0
&& extensions
.should_write(extension::end_of_index_entry::SIGNATURE)
.is_some()
&& !extension_toc.is_empty()
{
extension::end_of_index_entry::write_to(out, self.object_hash, offset_to_extensions, extension_toc)?;
}
Ok(version)
}
fn write_extensions<T>(
&self,
mut write: CountBytes<T>,
offset_to_extensions: u32,
extensions: Extensions,
) -> std::io::Result<(Vec<(extension::Signature, u32)>, T)>
where
T: std::io::Write,
{
type WriteExtFn<'a> = &'a dyn Fn(&mut dyn std::io::Write) -> Option<std::io::Result<extension::Signature>>;
let extensions: &[WriteExtFn<'_>] = &[
&|write| {
extensions
.should_write(extension::tree::SIGNATURE)
.and_then(|signature| self.tree().map(|tree| tree.write_to(write).map(|_| signature)))
},
&|write| {
self.is_sparse()
.then(|| extension::sparse::write_to(write).map(|_| extension::sparse::SIGNATURE))
},
];
let mut offset_to_previous_ext = offset_to_extensions;
let mut out = Vec::with_capacity(5);
for write_ext in extensions {
if let Some(signature) = write_ext(&mut write).transpose()? {
let offset_past_ext = write.count;
let ext_size = offset_past_ext - offset_to_previous_ext - (extension::MIN_SIZE as u32);
offset_to_previous_ext = offset_past_ext;
out.push((signature, ext_size));
}
}
Ok((out, write.inner))
}
}
impl State {
fn detect_required_version(&self) -> Version {
self.entries
.iter()
.find_map(|e| e.flags.contains(entry::Flags::EXTENDED).then_some(Version::V3))
.unwrap_or(Version::V2)
}
}
fn header<T: std::io::Write>(
out: &mut CountBytes<T>,
version: Version,
num_entries: u32,
) -> std::result::Result<u32, std::io::Error> {
let version = match version {
Version::V2 => 2_u32.to_be_bytes(),
Version::V3 => 3_u32.to_be_bytes(),
Version::V4 => 4_u32.to_be_bytes(),
};
out.write_all(crate::decode::header::SIGNATURE)?;
out.write_all(&version)?;
out.write_all(&num_entries.to_be_bytes())?;
Ok(out.count)
}
fn entries<T: std::io::Write>(
out: &mut CountBytes<T>,
state: &State,
header_size: u32,
) -> std::result::Result<u32, std::io::Error> {
for entry in state.entries() {
if entry.flags.contains(entry::Flags::REMOVE) {
continue;
}
entry.write_to(&mut *out, state)?;
match (out.count - header_size) % 8 {
0 => {}
n => {
let eight_null_bytes = [0u8; 8];
out.write_all(&eight_null_bytes[n as usize..])?;
}
}
}
Ok(out.count)
}
mod util {
pub struct CountBytes<T> {
pub count: u32,
pub inner: T,
}
impl<T> CountBytes<T>
where
T: std::io::Write,
{
pub fn new(inner: T) -> Self {
CountBytes { inner, count: 0 }
}
}
impl<T> std::io::Write for CountBytes<T>
where
T: std::io::Write,
{
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let written = self.inner.write(buf)?;
self.count = self
.count
.checked_add(u32::try_from(written).expect("we don't write 4GB buffers"))
.ok_or_else(|| std::io::Error::other("Cannot write indices larger than 4 gigabytes"))?;
Ok(written)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "dd39fb66e3b1551c9fe849043e39b357a9799ca1"
},
"path_in_vcs": "gix-odb"
}
+1035
View File
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
rust-version = "1.88"
name = "gix-odb"
version = "0.87.0"
authors = ["Sebastian Thiel <sebastian.thiel@icloud.com>"]
build = false
include = [
"/src/**/*",
"/LICENSE-*",
]
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Implements various git object databases"
readme = false
license = "MIT OR Apache-2.0"
repository = "https://github.com/GitoxideLabs/gitoxide"
resolver = "2"
[package.metadata.docs.rs]
features = [
"sha1",
"document-features",
"serde",
]
[features]
serde = [
"dep:serde",
"gix-hash/serde",
"gix-object/serde",
"gix-pack/serde",
]
sha1 = [
"gix-hash/sha1",
"gix-pack/sha1",
]
sha256 = [
"gix-hash/sha256",
"gix-pack/sha256",
]
[lib]
name = "gix_odb"
path = "src/lib.rs"
doctest = true
[dependencies.arc-swap]
version = "1.9.0"
[dependencies.crc32fast]
version = "1.5.0"
[dependencies.document-features]
version = "0.2.0"
optional = true
[dependencies.gix-error]
version = "^0.5.2"
[dependencies.gix-fs]
version = "^0.24.1"
features = ["walkdir"]
[dependencies.gix-hash]
version = "^0.28.1"
[dependencies.gix-hashtable]
version = "^0.18.0"
[dependencies.gix-object]
version = "^0.66.1"
[dependencies.gix-pack]
version = "^0.77.0"
default-features = false
[dependencies.gix-parallel]
version = "^0.50.0"
[dependencies.gix-path]
version = "^0.14.1"
[dependencies.gix-quote]
version = "^0.10.1"
[dependencies.gix-trace]
version = "^0.3.1"
[dependencies.gix-utils]
version = "^0.5.1"
features = ["progress"]
[dependencies.gix-zlib]
version = "^0.4.0"
[dependencies.memmap2]
version = "0.9.11"
[dependencies.parking_lot]
version = "0.12.4"
[dependencies.serde]
version = "1.0.114"
features = ["derive"]
optional = true
default-features = false
[dependencies.tempfile]
version = "3.26.0"
[dev-dependencies.crossbeam-channel]
version = "0.5.15"
[dev-dependencies.filetime]
version = "0.2.29"
[dev-dependencies.insta]
version = "1.46.3"
[dev-dependencies.maplit]
version = "1.0.2"
[dev-dependencies.pretty_assertions]
version = "1.0.0"
[lints.clippy]
assert_is_empty = "allow"
bool_to_int_with_if = "allow"
borrow_as_ptr = "allow"
cast_lossless = "allow"
cast_possible_truncation = "allow"
cast_possible_wrap = "allow"
cast_precision_loss = "allow"
cast_sign_loss = "allow"
checked_conversions = "allow"
copy_iterator = "allow"
default_trait_access = "allow"
doc_markdown = "allow"
empty_docs = "allow"
enum_glob_use = "allow"
explicit_deref_methods = "allow"
explicit_into_iter_loop = "allow"
explicit_iter_loop = "allow"
filter_map_next = "allow"
fn_params_excessive_bools = "allow"
if_not_else = "allow"
ignored_unit_patterns = "allow"
implicit_clone = "allow"
inconsistent_struct_constructor = "allow"
inefficient_to_string = "allow"
inline_always = "allow"
items_after_statements = "allow"
iter_not_returning_iterator = "allow"
iter_without_into_iter = "allow"
large_enum_variant = "allow"
large_stack_arrays = "allow"
manual_assert = "allow"
manual_is_variant_and = "allow"
manual_let_else = "allow"
manual_string_new = "allow"
many_single_char_names = "allow"
match_bool = "allow"
match_same_arms = "allow"
match_wild_err_arm = "allow"
match_wildcard_for_single_variants = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
module_name_repetitions = "allow"
must_use_candidate = "allow"
mut_mut = "allow"
naive_bytecount = "allow"
needless_continue = "allow"
needless_for_each = "allow"
needless_pass_by_value = "allow"
needless_raw_string_hashes = "allow"
no_effect_underscore_binding = "allow"
option_option = "allow"
range_plus_one = "allow"
redundant_else = "allow"
result_large_err = "allow"
return_self_not_must_use = "allow"
should_panic_without_expect = "allow"
similar_names = "allow"
single_match_else = "allow"
stable_sort_primitive = "allow"
struct_excessive_bools = "allow"
struct_field_names = "allow"
too_long_first_doc_paragraph = "allow"
too_many_lines = "allow"
transmute_ptr_to_ptr = "allow"
trivially_copy_pass_by_ref = "allow"
unnecessary_join = "allow"
unnecessary_wraps = "allow"
unreadable_literal = "allow"
unused_self = "allow"
used_underscore_binding = "allow"
wildcard_imports = "allow"
[lints.clippy.pedantic]
level = "warn"
priority = -1
[lints.rust]
+72
View File
@@ -0,0 +1,72 @@
lints.workspace = true
[package]
name = "gix-odb"
version = "0.87.0"
repository = "https://github.com/GitoxideLabs/gitoxide"
authors = ["Sebastian Thiel <sebastian.thiel@icloud.com>"]
license = "MIT OR Apache-2.0"
description = "Implements various git object databases"
edition = "2024"
include = ["/src/**/*", "/LICENSE-*"]
rust-version = "1.88"
autotests = false
[lib]
doctest = true
[features]
## Enable support for the SHA-1 hash by enabling the respective feature in the `gix-hash` crate.
sha1 = ["gix-hash/sha1", "gix-pack/sha1"]
## Enable support for the SHA-256 hash by enabling the respective feature in the `gix-hash` crate.
sha256 = ["gix-hash/sha256", "gix-pack/sha256"]
## Data structures implement `serde::Serialize` and `serde::Deserialize`.
serde = ["dep:serde", "gix-hash/serde", "gix-object/serde", "gix-pack/serde"]
[dependencies]
crc32fast = "1.5.0"
gix-trace = { version = "^0.3.1", path = "../gix-trace" }
gix-utils = { version = "^0.5.1", path = "../gix-utils", features = ["progress"] }
gix-parallel = { version = "^0.50.0", path = "../gix-parallel" }
gix-error = { version = "^0.5.2", path = "../gix-error" }
gix-zlib = { version = "^0.4.0", path = "../gix-zlib" }
gix-hashtable = { version = "^0.18.0", path = "../gix-hashtable" }
gix-hash = { version = "^0.28.1", path = "../gix-hash" }
gix-path = { version = "^0.14.1", path = "../gix-path" }
gix-quote = { version = "^0.10.1", path = "../gix-quote" }
gix-object = { version = "^0.66.1", path = "../gix-object" }
gix-pack = { version = "^0.77.0", path = "../gix-pack", default-features = false }
gix-fs = { version = "^0.24.1", path = "../gix-fs", features = ["walkdir"] }
serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"] }
tempfile = "3.26.0"
parking_lot = { version = "0.12.4" }
arc-swap = "1.9.0"
memmap2 = "0.9.11"
document-features = { version = "0.2.0", optional = true }
[dev-dependencies]
insta = "1.46.3"
gix-odb = { path = ".", features = ["sha1", "sha256"] }
gix-hash = { path = "../gix-hash" }
gix-testtools = { path = "../tests/tools", default-features = false, features = ["sha1", "sha256"] }
gix-date = { path = "../gix-date" }
gix-pack = { path = "../gix-pack", features = ["sha1"] }
gix-actor = { path = "../gix-actor" }
pretty_assertions = "1.0.0"
filetime = "0.2.29"
maplit = "1.0.2"
crossbeam-channel = "0.5.15"
[[test]]
name = "odb"
path = "tests/odb/main.rs"
[[test]]
name = "threaded"
path = "tests/threaded.rs"
required-features = ["gix-parallel/parallel"]
[package.metadata.docs.rs]
features = ["sha1", "document-features", "serde"]
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+17
View File
@@ -0,0 +1,17 @@
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+112
View File
@@ -0,0 +1,112 @@
//! A file with directories of other git object databases to use when reading objects.
//!
//! This inherently makes alternates read-only.
//!
//! An alternate file in `<git-dir>/info/alternates` can look as follows:
//!
//! ```text
//! # a comment, empty lines are also allowed
//! # relative paths resolve relative to the parent git repository
//! ../path/relative/to/repo/.git
//! /absolute/path/to/repo/.git
//!
//! "/a/ansi-c-quoted/path/with/tabs\t/.git"
//!
//! # each .git directory should indeed be a directory, and not a file
//! ```
//!
//! Based on the [canonical implementation](https://github.com/git/git/blob/master/sha1-file.c#L598:L609).
use gix_error::Result;
use std::{fs, io, path::PathBuf};
use gix_error::{ErrorExt, Message, ResultExt, bail};
use gix_path::realpath::MAX_SYMLINKS;
mod parse;
pub use parse::parse;
/// An alternate object directory points back into the chain being resolved.
#[derive(Debug)]
pub struct Cycle {
/// Canonical object directories in traversal order, with an implicit link from the last to the first.
pub paths: Vec<PathBuf>,
}
impl std::fmt::Display for Cycle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Alternates form a cycle")?;
for path in &self.paths {
write!(f, " -> {}", path.display())?;
}
Ok(())
}
}
impl std::error::Error for Cycle {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(const { &gix_error::ClassificationMarker::CORRUPTION })
}
}
/// Given an `objects_directory`, try to resolve alternate object directories possibly located in the
/// `./info/alternates` file into canonical paths and resolve relative paths with the help of the `current_dir`.
/// If no alternate object database was resolved, the resulting `Vec` is empty (it is not an error
/// if there are no alternates).
/// An object directory that was resolved before is skipped, and it is an error if an alternate points back
/// into the chain of directories that is currently being followed, as that would form a cycle.
/// Read and parse failures include [metadata](gix_error::Error::metadata()) `path` (native path), the alternates file.
/// Cycles retain their canonical directory chain in [`Cycle`].
pub fn resolve(objects_directory: PathBuf, current_dir: &std::path::Path) -> Result<Vec<PathBuf>> {
let mut dirs = vec![(None, objects_directory.clone())];
let mut out = Vec::new();
let mut seen = Vec::new();
while let Some((parent_idx, dir)) = dirs.pop() {
let dir_canonicalized = gix_path::realpath_opts(&dir, current_dir, MAX_SYMLINKS)?;
if let Some(seen_idx) = seen.iter().position(|(seen_dir, _)| *seen_dir == dir_canonicalized) {
if let Some(parent_idx) = parent_idx
&& chain(&seen, parent_idx).any(|ancestor| ancestor == seen_idx)
{
let mut cycle: Vec<_> = chain(&seen, parent_idx)
.take_while(|ancestor| *ancestor != seen_idx)
.map(|idx| seen[idx].0.clone())
.collect();
cycle.push(seen[seen_idx].0.clone());
cycle.reverse();
bail!(Cycle { paths: cycle });
}
continue;
}
let idx = seen.len();
seen.push((dir_canonicalized, parent_idx));
let path = dir.join("info").join("alternates");
match fs::read(&path) {
Ok(input) => {
for path in parse(&input)
.or_raise(|| Message::new("Could not parse alternates").with("path", path))?
.into_iter()
.rev()
{
dirs.push((Some(idx), objects_directory.join(path)));
}
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => {
bail!(err.and_raise(Message::new("Could not read alternates").with("path", path)));
}
}
if parent_idx.is_some() {
out.push(dir);
}
}
Ok(out)
}
/// Yield `idx` and the indices of all directories it was reached through, starting at `idx`.
fn chain(seen: &[(PathBuf, Option<usize>)], idx: usize) -> impl Iterator<Item = usize> + '_ {
let mut next = Some(idx);
std::iter::from_fn(move || {
let idx = next?;
next = seen[idx].1;
Some(idx)
})
}
+44
View File
@@ -0,0 +1,44 @@
use gix_error::Result;
use gix_error::{Message, ResultExt};
use std::{borrow::Cow, path::PathBuf};
use gix_object::bstr::ByteSlice;
/// Parse the raw contents of an `objects/info/alternates` file from `input` into paths.
///
/// Empty entries and comments are ignored. Entries beginning with `"` use Git's C-style quoting,
/// which permits literal newlines in paths. Invalid quoting falls back to the raw entry.
/// Path conversion failures include [metadata](gix_error::Error::metadata()) `input` (bytes), the original alternates
/// entry.
pub fn parse(mut input: &[u8]) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
while !input.is_empty() {
let entry = input.as_bstr();
let end_of_line = || entry.find_byte(b'\n').unwrap_or(entry.len());
let (path, consumed) = if entry.starts_with(b"#") {
(None, end_of_line())
} else {
// Like Git, try unquoting before treating a newline as the next separator.
match entry.starts_with(b"\"").then(|| gix_quote::ansi_c::undo(entry)) {
Some(Ok((unquoted, consumed))) => (Some(unquoted), consumed),
_ => {
let consumed = end_of_line();
(Some(Cow::Borrowed(entry[..consumed].as_bstr())), consumed)
}
}
};
let original = &entry[..consumed];
let maybe_nl = usize::from(consumed < input.len());
input = &input[consumed + maybe_nl..];
let Some(path) = path.filter(|path| !path.is_empty()) else {
continue;
};
out.push(
gix_path::from_bstr(path)
.or_raise(|| Message::new("Could not obtain an alternate object directory").with_input(original))?
.into_owned(),
);
}
Ok(out)
}
+254
View File
@@ -0,0 +1,254 @@
use std::{
cell::RefCell,
ops::{Deref, DerefMut},
rc::Rc,
sync::Arc,
};
use crate::Cache;
/// A type to store pack caches in boxes.
pub type PackCache = dyn gix_pack::cache::DecodeEntry + Send + 'static;
/// A constructor for boxed pack caches.
pub type NewPackCacheFn = dyn Fn() -> Box<PackCache> + Send + Sync + 'static;
/// A type to store object caches in boxes.
pub type ObjectCache = dyn gix_pack::cache::Object + Send + 'static;
/// A constructor for boxed object caches.
pub type NewObjectCacheFn = dyn Fn() -> Box<ObjectCache> + Send + Sync + 'static;
impl Cache<crate::store::Handle<Rc<crate::Store>>> {
/// Convert this cache's handle into one that keeps its store in an arc. This creates an entirely new store,
/// so should be done early to avoid unnecessary work (and mappings).
pub fn into_arc(self) -> std::io::Result<Cache<crate::store::Handle<Arc<crate::Store>>>> {
let inner = self.inner.into_arc()?;
Ok(Cache {
inner,
new_pack_cache: self.new_pack_cache,
new_object_cache: self.new_object_cache,
pack_cache: self.pack_cache,
object_cache: self.object_cache,
})
}
}
impl Cache<crate::store::Handle<Arc<crate::Store>>> {
/// No op, as we are containing an arc handle already.
pub fn into_arc(self) -> std::io::Result<Cache<crate::store::Handle<Arc<crate::Store>>>> {
Ok(self)
}
}
impl<S> Cache<S> {
/// Dissolve this instance, discard all caches, and return the inner implementation.
pub fn into_inner(self) -> S {
self.inner
}
/// Use this methods directly after creating a new instance to add a constructor for pack caches.
///
/// These are used to speed up decoding objects which are located in packs, reducing long delta chains by storing
/// their intermediate results.
pub fn with_pack_cache(mut self, create: impl Fn() -> Box<PackCache> + Send + Sync + 'static) -> Self {
self.pack_cache = Some(RefCell::new(create()));
self.new_pack_cache = Some(Arc::new(create));
self
}
/// Use this methods directly after creating a new instance to add a constructor for object caches.
///
/// Only use this kind of cache if the same objects are repeatedly accessed for great speedups, usually during diffing of
/// trees.
pub fn with_object_cache(mut self, create: impl Fn() -> Box<ObjectCache> + Send + Sync + 'static) -> Self {
self.object_cache = Some(RefCell::new(create()));
self.new_object_cache = Some(Arc::new(create));
self
}
/// Set the pack cache constructor on this instance.
pub fn set_pack_cache(&mut self, create: impl Fn() -> Box<PackCache> + Send + Sync + 'static) {
self.pack_cache = Some(RefCell::new(create()));
self.new_pack_cache = Some(Arc::new(create));
}
/// Set the object cache constructor on this instance.
pub fn set_object_cache(&mut self, create: impl Fn() -> Box<ObjectCache> + Send + Sync + 'static) {
self.object_cache = Some(RefCell::new(create()));
self.new_object_cache = Some(Arc::new(create));
}
/// Return true if an object cache is present.
pub fn has_object_cache(&self) -> bool {
self.object_cache.is_some()
}
/// Return true if a pack cache is present.
pub fn has_pack_cache(&self) -> bool {
self.pack_cache.is_some()
}
/// Remove the current pack cache as well as its constructor from this instance.
pub fn unset_pack_cache(&mut self) {
self.pack_cache = None;
self.new_pack_cache = None;
}
/// Remove the current object cache as well as its constructor from this instance.
pub fn unset_object_cache(&mut self) {
self.object_cache = None;
self.new_object_cache = None;
}
}
impl<S> From<S> for Cache<S>
where
S: gix_pack::Find,
{
fn from(store: S) -> Self {
Self {
inner: store,
pack_cache: None,
new_pack_cache: None,
object_cache: None,
new_object_cache: None,
}
}
}
impl<S: Clone> Clone for Cache<S> {
fn clone(&self) -> Self {
Cache {
inner: self.inner.clone(),
new_pack_cache: self.new_pack_cache.clone(),
new_object_cache: self.new_object_cache.clone(),
pack_cache: self.new_pack_cache.as_ref().map(|create| RefCell::new(create())),
object_cache: self.new_object_cache.as_ref().map(|create| RefCell::new(create())),
}
}
}
impl<S> Deref for Cache<S> {
type Target = S;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<S> DerefMut for Cache<S> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
mod impls {
use gix_error::Result;
use std::{cell::RefCell, io::Read, ops::DerefMut};
use gix_hash::{ObjectId, oid};
use gix_object::{Data, Kind};
use gix_pack::cache::Object;
use crate::{Cache, find::Header, pack::data::entry::Location};
impl<S> gix_object::Write for Cache<S>
where
S: gix_object::Write,
{
fn write_stream(&self, kind: Kind, size: u64, from: &mut dyn Read) -> Result<ObjectId> {
self.inner.write_stream(kind, size, from)
}
fn write_buf_with_known_id(&self, kind: Kind, from: &[u8], id: ObjectId) -> Result<ObjectId> {
self.inner.write_buf_with_known_id(kind, from, id)
}
fn write_stream_with_known_id(
&self,
kind: Kind,
size: u64,
from: &mut dyn Read,
id: ObjectId,
) -> Result<ObjectId> {
self.inner.write_stream_with_known_id(kind, size, from, id)
}
}
impl<S> gix_object::Find for Cache<S>
where
S: gix_pack::Find,
{
fn try_find<'a>(&self, id: &oid, buffer: &'a mut Vec<u8>) -> Result<Option<Data<'a>>> {
gix_pack::Find::try_find(self, id, buffer).map(|t| t.map(|t| t.0))
}
}
impl<S> gix_object::Exists for Cache<S>
where
S: gix_pack::Find,
{
fn exists(&self, id: &oid) -> bool {
self.inner.contains(id)
}
}
impl<S> crate::Header for Cache<S>
where
S: crate::Header,
{
fn try_header(&self, id: &oid) -> Result<Option<Header>> {
self.inner.try_header(id)
}
}
impl<S> gix_object::FindHeader for Cache<S>
where
S: gix_object::FindHeader,
{
fn try_header(&self, id: &oid) -> Result<Option<gix_object::Header>> {
self.inner.try_header(id)
}
}
impl<S> gix_pack::Find for Cache<S>
where
S: gix_pack::Find,
{
fn contains(&self, id: &oid) -> bool {
self.inner.contains(id)
}
fn try_find<'a>(&self, id: &oid, buffer: &'a mut Vec<u8>) -> Result<Option<(Data<'a>, Option<Location>)>> {
match self.pack_cache.as_ref().map(RefCell::borrow_mut) {
Some(mut pack_cache) => self.try_find_cached(id, buffer, pack_cache.deref_mut()),
None => self.try_find_cached(id, buffer, &mut gix_pack::cache::Never),
}
}
fn try_find_cached<'a>(
&self,
id: &oid,
buffer: &'a mut Vec<u8>,
pack_cache: &mut dyn gix_pack::cache::DecodeEntry,
) -> Result<Option<(Data<'a>, Option<gix_pack::data::entry::Location>)>> {
if let Some(mut obj_cache) = self.object_cache.as_ref().map(RefCell::borrow_mut)
&& let Some(kind) = obj_cache.get(&id.as_ref().to_owned(), buffer)
{
return Ok(Some((Data::new(buffer, kind, id.kind()), None)));
}
let possibly_obj = self.inner.try_find_cached(id.as_ref(), buffer, pack_cache)?;
if let (Some(mut obj_cache), Some((obj, _location))) =
(self.object_cache.as_ref().map(RefCell::borrow_mut), &possibly_obj)
{
obj_cache.put(id.as_ref().to_owned(), obj.kind, obj.data);
}
Ok(possibly_obj)
}
fn location_by_oid(&self, id: &oid, buf: &mut Vec<u8>) -> Result<Option<gix_pack::data::entry::Location>> {
self.inner.location_by_oid(id, buf)
}
fn pack_offsets_and_oid(
&self,
pack_id: u32,
) -> Result<Option<Vec<(gix_pack::data::Offset, gix_hash::ObjectId)>>> {
self.inner.pack_offsets_and_oid(pack_id)
}
fn entry_by_location(&self, location: &Location) -> Option<gix_pack::find::Entry> {
self.inner.entry_by_location(location)
}
}
}
+62
View File
@@ -0,0 +1,62 @@
/// An object header informing about object properties, without it being fully decoded in the process.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub enum Header {
/// The object was not packed, but is currently located in the loose object portion of the database.
///
/// As packs are searched first, this means that in this very moment, the object whose header we retrieved is unique
/// in the object database.
Loose {
/// The kind of the object.
kind: gix_object::Kind,
/// The size of the object's data in bytes.
size: u64,
},
/// The object was present in a pack.
///
/// Note that this does not imply it is unique in the database, as it might be present in more than one pack and even
/// as loose object.
Packed(gix_pack::data::decode::header::Outcome),
}
mod header {
use super::Header;
impl Header {
/// Return the object kind of the object we represent.
pub fn kind(&self) -> gix_object::Kind {
match self {
Header::Packed(out) => out.kind,
Header::Loose { kind, .. } => *kind,
}
}
/// Return the size of the object in bytes.
pub fn size(&self) -> u64 {
match self {
Header::Packed(out) => out.object_size,
Header::Loose { size, .. } => *size,
}
}
/// Return the amount of deltas decoded to obtain this header, if the object was packed.
pub fn num_deltas(&self) -> Option<u32> {
match self {
Header::Packed(out) => out.num_deltas.into(),
Header::Loose { .. } => None,
}
}
}
impl From<gix_pack::data::decode::header::Outcome> for Header {
fn from(packed_header: gix_pack::data::decode::header::Outcome) -> Self {
Header::Packed(packed_header)
}
}
impl From<(u64, gix_object::Kind)> for Header {
fn from((object_size, kind): (u64, gix_object::Kind)) -> Self {
Header::Loose {
kind,
size: object_size,
}
}
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Git stores all of its data as _Objects_, which are data along with a hash over all data. Thus it's an
//! object store indexed by the signature of data itself with inherent deduplication: the same data will have the same hash,
//! and thus occupy the same space within the store.
//!
//! There is only one all-round object store, also known as the [`Store`], as it supports ~~everything~~ most of what git has to offer.
//!
//! * loose object reading and writing
//! * access to packed objects
//! * multiple loose objects and pack locations as gathered from `alternates` files.
//!
//! Errors preserve their original sources and classifications through [`gix_error::Exn`] and [`gix_error::Error`].
//! [`gix_error::Message`] contexts carry diagnostic details such as native paths, object IDs as hex text,
//! and unsigned sizes or limits. Functions creating these contexts document their keys and value types.
//! Use [`gix_error::Error::metadata()`] to inspect each dictionary,
//! skipping empty ones. Synthetic failures combine their message, class, and values
//! in one diagnostic; real callee errors remain separate causes.
//! Alternate cycles retain their directory chain in [`alternate::Cycle`] and expose a classification-only
//! [`gix_error::ClassificationMarker`] source. Use [`gix_error::classify()`] or `is_corrupted()` on [`gix_error::Exn`]
//! and [`gix_error::Error`] without depending on the concrete diagnostic type.
//! Retryable verification diagnostics use [`gix_error::retryable()`]. Interrupted verification retains its original
//! I/O error and uses [`gix_error::ClassificationMarker`] with [`gix_error::Class::Cancelled`].
//!
//! ## Write And Read Loose Objects
//!
//! ```
//! # fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
//! # mod doctest { include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/doctest.rs")); }
//! use gix_object::{FindExt, Write};
//!
//! let (_dir, odb) = doctest::empty_store()?;
//! let id = odb.write_buf(gix_object::Kind::Blob, b"hello")?;
//!
//! let mut buf = Vec::new();
//! let object = odb.find(&id, &mut buf)?;
//! assert_eq!(object.kind, gix_object::Kind::Blob);
//! assert_eq!(object.data, b"hello");
//! # Ok(()) }
//! ```
//!
//! ## Inspect Headers Without Decoding The Object
//!
//! ```
//! # fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
//! # mod doctest { include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/doctest.rs")); }
//! use gix_object::Write;
//! use gix_odb::HeaderExt;
//!
//! let (_dir, odb) = doctest::empty_store()?;
//! let id = odb.write_buf(gix_object::Kind::Blob, b"hello")?;
//!
//! let header = odb.header(&id)?;
//! assert_eq!(header.kind(), gix_object::Kind::Blob);
//! assert_eq!(header.size(), 5);
//! # Ok(()) }
//! ```
//! ## Feature Flags
#![cfg_attr(
all(doc, feature = "document-features"),
doc = ::document_features::document_features!()
)]
#![cfg_attr(all(doc, feature = "document-features"), feature(doc_cfg))]
#![deny(missing_docs, unsafe_code)]
use std::{
cell::RefCell,
path::PathBuf,
sync::{Arc, atomic::AtomicUsize},
};
use arc_swap::ArcSwap;
pub use gix_pack as pack;
use gix_parallel::OwnShared;
use gix_zlib::stream::deflate;
mod store_impls;
pub use store_impls::{dynamic as store, loose};
pub mod alternate;
/// A way to access objects along with pre-configured thread-local caches for packed base objects as well as objects themselves.
///
/// By default, no cache will be used.
pub struct Cache<S> {
/// The inner provider of trait implementations we use in conjunction with our caches.
///
/// For calling methods on `inner`, prefer to make use of auto-dereferencing, i.e. `cache.inner_method()` instead of `cache.inner.inner_method()`.
inner: S,
// TODO: have single-threaded code-paths also for pack-creation (entries from counts) so that we can use OwnShared here
// instead of Arc. However, it's probably not that important as these aren't called often.
new_pack_cache: Option<Arc<cache::NewPackCacheFn>>,
new_object_cache: Option<Arc<cache::NewObjectCacheFn>>,
pack_cache: Option<RefCell<Box<cache::PackCache>>>,
object_cache: Option<RefCell<Box<cache::ObjectCache>>>,
}
///
pub mod cache;
///
/// It can optionally compress the content, similarly to what would happen when using a [`loose::Store`].
///
#[derive(Clone)]
pub struct Sink {
compressor: Option<RefCell<deflate::Write<std::io::Sink>>>,
object_hash: gix_hash::Kind,
}
/// Create a new [`Sink`] with compression disabled.
pub fn sink(object_hash: gix_hash::Kind) -> Sink {
Sink {
compressor: None,
object_hash,
}
}
///
pub mod memory;
mod sink;
///
pub mod find;
/// An object database equivalent to `/dev/null`, dropping all objects stored into it.
mod traits;
pub use traits::{Header, HeaderExt};
/// A thread-local handle to access any object.
pub type Handle = Cache<store::Handle<OwnShared<Store>>>;
/// A thread-local handle to access any object, but thread-safe and independent of the actual type of `OwnShared` or feature toggles in `gix-parallel`.
pub type HandleArc = Cache<store::Handle<Arc<Store>>>;
use store::types;
/// The object store for use in any applications with support for auto-updates in the light of changes to the object database.
///
/// ### Features
///
/// - entirely lazy, creating an instance does no disk IO at all if [`Slots::Given`][store::init::Slots::Given] is used.
/// - multi-threaded lazy-loading of indices and packs
/// - per-thread pack and object caching avoiding cache trashing.
/// - most-recently-used packs are always first for speedups if objects are stored in the same pack, typical for packs organized by
/// commit graph and object age.
/// - lock-free reading for perfect scaling across all cores, and changes to it don't affect readers as long as these don't want to
/// enter the same branch.
/// - sync with the state on disk if objects aren't found to catch up with changes if an object seems to be missing.
/// - turn off the behaviour above for all handles if objects are expected to be missing due to spare checkouts.
pub struct Store {
/// The central write lock without which the slotmap index can't be changed.
write: parking_lot::Mutex<()>,
/// The source directory from which all content is loaded, and the central write lock for use when a directory refresh is needed.
pub(crate) path: PathBuf,
/// The current working directory at the time this store was instantiated. It becomes relevant when resolving alternate paths
/// when re-reading the store configuration on updates when an object was missed.
/// Keeping it here helps to assure consistency even while a process changes its CWD.
pub(crate) current_dir: PathBuf,
/// A set of replacements that given a source OID return a destination OID. The vector is sorted.
pub(crate) replacements: Vec<(gix_hash::ObjectId, gix_hash::ObjectId)>,
/// A list of indices keeping track of which slots are filled with data. These are usually, but not always, consecutive.
pub(crate) index: ArcSwap<types::SlotMapIndex>,
/// The below state acts like a slot-map with each slot is mutable when the write lock is held, but readable independently of it.
/// This allows multiple file to be loaded concurrently if there is multiple handles requesting to load packs or additional indices.
/// The map is static and cannot change.
/// It's read often and changed rarely.
pub(crate) files: Vec<types::MutableIndexAndPack>,
/// The amount of handles that would prevent us from unloading packs or indices
pub(crate) num_handles_stable: AtomicUsize,
/// The amount of handles that don't affect our ability to compact our internal data structures or unload packs or indices.
pub(crate) num_handles_unstable: AtomicUsize,
/// The amount of times we re-read the disk state to consolidate our in-memory representation.
pub(crate) num_disk_state_consolidation: AtomicUsize,
/// If true, we are allowed to use multi-pack indices and they must have the `object_hash` or be ignored.
use_multi_pack_index: bool,
/// The hash kind to use for some operations
object_hash: gix_hash::Kind,
/// The maximum size of a single allocation caused by user-controlled on-disk pack data.
alloc_limit_bytes: Option<usize>,
/// The compression level to use when writing loose objects.
loose_compression: gix_zlib::Compression,
}
/// Create a new cached handle to the object store with support for additional options.
///
/// `object_hash` is the hash of contained objects and the hash used when writing objects.
/// `replacements` is an iterator over pairs of old and new object ids for replacement support.
/// This means that when asking for object `X`, one will receive object `X-replaced` given an iterator like `Some((X, X-replaced))`.
pub fn at_opts(
objects_dir: impl Into<PathBuf>,
object_hash: gix_hash::Kind,
replacements: impl IntoIterator<Item = (gix_hash::ObjectId, gix_hash::ObjectId)>,
options: store::init::Options,
) -> std::io::Result<Handle> {
let handle = OwnShared::new(Store::at_opts(
objects_dir.into(),
object_hash,
&mut replacements.into_iter(),
options,
)?)
.to_handle();
Ok(Cache::from(handle))
}
/// Create a new cached handle to the object store with `.git/objects` provided in `objects_dir`,
/// with `object_hash` as the hash of contained objects to write.
pub fn at(objects_dir: impl Into<PathBuf>, object_hash: gix_hash::Kind) -> std::io::Result<Handle> {
at_opts(objects_dir, object_hash, None, store::init::Options::default())
}
+316
View File
@@ -0,0 +1,316 @@
use gix_error::Result;
use std::{
cell::RefCell,
ops::{Deref, DerefMut},
rc::Rc,
sync::Arc,
};
use gix_error::ResultExt;
use gix_object::Data;
use crate::{Cache, find::Header};
/// An object database to read from any implementation but write to memory.
/// Previously written objects can be returned from memory upon query, which makes the view of objects consistent.
/// In-Memory objects can be disabled by [taking out its storage](Proxy::take_object_memory). From there in-memory
/// object can also be persisted one by one.
///
/// It's possible to turn off the memory by removing it from the instance.
pub struct Proxy<T> {
/// The actual odb implementation
inner: T,
/// The kind of hash to produce when writing new objects.
object_hash: gix_hash::Kind,
/// The storage for in-memory objects.
/// If `None`, the proxy will always read from and write-through to `inner`.
memory: Option<RefCell<Storage>>,
}
/// Lifecycle
impl<T> Proxy<T> {
/// Create a new instance using `odb` as actual object provider, with an empty in-memory store for
/// objects that are to be written.
/// Use `object_hash` to determine the kind of hash to produce when writing new objects.
pub fn new(odb: T, object_hash: gix_hash::Kind) -> Proxy<T> {
Proxy {
inner: odb,
object_hash,
memory: Some(Default::default()),
}
}
/// Turn ourselves into our inner object database, while deallocating objects stored in memory.
pub fn into_inner(self) -> T {
self.inner
}
/// Strip object memory off this instance, which means that writes will go through to the inner object database
/// right away.
/// This mode makes the proxy fully transparent.
pub fn with_write_passthrough(mut self) -> Self {
self.memory.take();
self
}
}
impl Proxy<Cache<crate::store::Handle<Arc<crate::Store>>>> {
/// No op, as we are containing an arc handle already.
pub fn into_arc(self) -> std::io::Result<Proxy<Cache<crate::store::Handle<Arc<crate::Store>>>>> {
Ok(self)
}
}
impl Proxy<Cache<crate::store::Handle<Rc<crate::Store>>>> {
/// Create an entirely new instance, but with the in-memory objects moving between them.
pub fn into_arc(self) -> std::io::Result<Proxy<Cache<crate::store::Handle<Arc<crate::Store>>>>> {
Ok(Proxy {
inner: self.inner.into_arc()?,
object_hash: self.object_hash,
memory: self.memory,
})
}
}
impl From<crate::Handle> for Proxy<crate::Handle> {
fn from(odb: crate::Handle) -> Self {
let object_hash = odb.store.object_hash;
Proxy::new(odb, object_hash)
}
}
/// Memory Access
impl<T> Proxy<T> {
/// Take all the objects in memory so far, with the memory storage itself and return it.
///
/// The instance will remain in a state where it won't be able to store objects in memory at all,
/// they will now be stored in the underlying object database.
/// This mode makes the proxy fully transparent.
///
/// To avoid that, use [`reset_object_memory()`](Self::reset_object_memory()) or return the storage
/// using [`set_object_memory()`](Self::set_object_memory()).
pub fn take_object_memory(&mut self) -> Option<Storage> {
self.memory.take().map(RefCell::into_inner)
}
/// Set the object storage to contain only `new` objects, and return whichever objects were there previously.
pub fn set_object_memory(&mut self, new: Storage) -> Option<Storage> {
let previous = self.take_object_memory();
self.memory = Some(RefCell::new(new));
previous
}
/// If objects aren't written to memory yet, this will happen after the call.
///
/// Otherwise, no change will be performed.
pub fn enable_object_memory(&mut self) -> &mut Self {
if self.memory.is_none() {
self.memory = Some(Default::default());
}
self
}
/// Reset the internal storage to be empty, and return the previous storage, with all objects
/// it contained.
///
/// Note that this does nothing if this instance didn't contain object memory in the first place.
/// In that case, set it explicitly.
pub fn reset_object_memory(&self) -> Option<Storage> {
self.memory.as_ref().map(|m| std::mem::take(&mut *m.borrow_mut()))
}
/// Return the amount of objects currently stored in memory.
pub fn num_objects_in_memory(&self) -> usize {
self.memory.as_ref().map_or(0, |m| m.borrow().len())
}
}
impl<T> Clone for Proxy<T>
where
T: Clone,
{
fn clone(&self) -> Self {
Proxy {
inner: self.inner.clone(),
object_hash: self.object_hash,
memory: self.memory.clone(),
}
}
}
impl<T> gix_object::Find for Proxy<T>
where
T: gix_object::Find,
{
fn try_find<'a>(&self, id: &gix_hash::oid, buffer: &'a mut Vec<u8>) -> Result<Option<Data<'a>>> {
if let Some(map) = self.memory.as_ref() {
let map = map.borrow();
if let Some((kind, data)) = map.get(id) {
buffer.clear();
buffer.extend_from_slice(data);
return Ok(Some(Data {
kind: *kind,
object_hash: id.kind(),
data: &*buffer,
}));
}
}
self.inner.try_find(id, buffer)
}
}
impl<T> gix_object::Exists for Proxy<T>
where
T: gix_object::Exists,
{
fn exists(&self, id: &gix_hash::oid) -> bool {
self.memory.as_ref().is_some_and(|map| map.borrow().contains_key(id)) || self.inner.exists(id)
}
}
impl<S> Proxy<Cache<crate::store::Handle<S>>>
where
S: Deref<Target = crate::Store> + Clone,
{
/// Like [`exists()`](gix_object::Exists::exists), but never look for packs that were added since the last refresh.
/// See [`Handle::exists_without_refresh()`](crate::store::Handle::exists_without_refresh()).
pub fn exists_without_refresh(&self, id: &gix_hash::oid) -> bool {
self.memory.as_ref().is_some_and(|map| map.borrow().contains_key(id)) || self.inner.exists_without_refresh(id)
}
}
impl<T> crate::Header for Proxy<T>
where
T: crate::Header,
{
fn try_header(&self, id: &gix_hash::oid) -> Result<Option<Header>> {
if let Some(map) = self.memory.as_ref() {
let map = map.borrow();
if let Some((kind, data)) = map.get(id) {
return Ok(Some(Header::Loose {
kind: *kind,
size: data.len() as u64,
}));
}
}
self.inner.try_header(id)
}
}
impl<T> gix_object::FindHeader for Proxy<T>
where
T: gix_object::FindHeader,
{
fn try_header(&self, id: &gix_hash::oid) -> Result<Option<gix_object::Header>> {
if let Some(map) = self.memory.as_ref() {
let map = map.borrow();
if let Some((kind, data)) = map.get(id) {
return Ok(Some(gix_object::Header {
kind: *kind,
size: data.len() as u64,
}));
}
}
self.inner.try_header(id)
}
}
impl<T> gix_object::Write for Proxy<T>
where
T: gix_object::Write,
{
fn write(&self, object: &dyn gix_object::WriteTo) -> Result<gix_hash::ObjectId> {
let Some(map) = self.memory.as_ref() else {
return self.inner.write(object);
};
let mut buf = Vec::with_capacity(2048);
object.write_to(&mut buf).or_error()?;
let kind = object.kind();
let id = gix_object::compute_hash(self.object_hash, kind, &buf)?;
map.borrow_mut().entry(id).or_insert((kind, buf));
Ok(id)
}
fn write_stream(
&self,
kind: gix_object::Kind,
size: u64,
from: &mut dyn std::io::Read,
) -> Result<gix_hash::ObjectId> {
let Some(map) = self.memory.as_ref() else {
return self.inner.write_stream(kind, size, from);
};
let mut buf = Vec::new();
from.read_to_end(&mut buf).or_error()?;
let id = gix_object::compute_hash(self.object_hash, kind, &buf)?;
map.borrow_mut().entry(id).or_insert((kind, buf));
Ok(id)
}
fn write_buf_with_known_id(
&self,
kind: gix_object::Kind,
from: &[u8],
id: gix_hash::ObjectId,
) -> Result<gix_hash::ObjectId> {
let Some(map) = self.memory.as_ref() else {
return self.inner.write_buf_with_known_id(kind, from, id);
};
map.borrow_mut().entry(id).or_insert_with(|| (kind, from.to_owned()));
Ok(id)
}
fn write_stream_with_known_id(
&self,
kind: gix_object::Kind,
size: u64,
from: &mut dyn std::io::Read,
id: gix_hash::ObjectId,
) -> Result<gix_hash::ObjectId> {
let Some(map) = self.memory.as_ref() else {
return self.inner.write_stream_with_known_id(kind, size, from, id);
};
let mut buf = Vec::new();
from.read_to_end(&mut buf).or_error()?;
map.borrow_mut().entry(id).or_insert((kind, buf));
Ok(id)
}
}
impl<T> Deref for Proxy<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<T> DerefMut for Proxy<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
/// A mapping between an object id and all data corresponding to an object, acting like a `HashMap<ObjectID, (Kind, Data)>`.
#[derive(Default, Debug, Clone, Eq, PartialEq)]
pub struct Storage(gix_hashtable::HashMap<gix_hash::ObjectId, (gix_object::Kind, Vec<u8>)>);
impl Deref for Storage {
type Target = gix_hashtable::HashMap<gix_hash::ObjectId, (gix_object::Kind, Vec<u8>)>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Storage {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
+99
View File
@@ -0,0 +1,99 @@
use gix_error::Result;
use std::{
cell::RefCell,
io::{self, Write},
};
use gix_error::ResultExt;
use gix_zlib::stream::deflate;
use crate::Sink;
impl Sink {
/// Compress with the given level, or disable compression with `None`. Compression is disabled by default.
pub fn compress(mut self, compression: Option<gix_zlib::Compression>) -> Self {
self.compressor = compression.map(|level| RefCell::new(deflate::Write::new(io::sink(), level)));
self
}
}
impl gix_object::Write for Sink {
fn write_buf_with_known_id(
&self,
kind: gix_object::Kind,
mut from: &[u8],
id: gix_hash::ObjectId,
) -> Result<gix_hash::ObjectId> {
self.write_stream_with_known_id(kind, from.len() as u64, &mut from, id)
}
fn write_stream(
&self,
kind: gix_object::Kind,
mut size: u64,
from: &mut dyn io::Read,
) -> Result<gix_hash::ObjectId> {
let mut buf = [0u8; u16::MAX as usize];
let header = gix_object::encode::loose_header(kind, size);
let possibly_compress = |buf: &[u8]| -> io::Result<()> {
if let Some(compressor) = self.compressor.as_ref() {
compressor.try_borrow_mut().expect("no recursion").write_all(buf)?;
}
Ok(())
};
let mut hasher = gix_hash::hasher(self.object_hash);
hasher.update(&header);
possibly_compress(&header).or_error()?;
while size != 0 {
let bytes = (size as usize).min(buf.len());
from.read_exact(&mut buf[..bytes]).or_error()?;
hasher.update(&buf[..bytes]);
possibly_compress(&buf[..bytes]).or_error()?;
size -= bytes as u64;
}
if let Some(compressor) = self.compressor.as_ref() {
let mut c = compressor.borrow_mut();
c.flush().or_error()?;
c.reset();
}
hasher.try_finalize()
}
fn write_stream_with_known_id(
&self,
kind: gix_object::Kind,
mut size: u64,
from: &mut dyn io::Read,
id: gix_hash::ObjectId,
) -> Result<gix_hash::ObjectId> {
let mut buf = [0u8; u16::MAX as usize];
let header = gix_object::encode::loose_header(kind, size);
let possibly_compress = |buf: &[u8]| -> io::Result<()> {
if let Some(compressor) = self.compressor.as_ref() {
compressor.try_borrow_mut().expect("no recursion").write_all(buf)?;
}
Ok(())
};
possibly_compress(&header).or_error()?;
while size != 0 {
let bytes = (size as usize).min(buf.len());
from.read_exact(&mut buf[..bytes]).or_error()?;
possibly_compress(&buf[..bytes]).or_error()?;
size -= bytes as u64;
}
if let Some(compressor) = self.compressor.as_ref() {
let mut c = compressor.borrow_mut();
c.flush().or_error()?;
c.reset();
}
Ok(id)
}
}
@@ -0,0 +1,24 @@
use crate::Store;
impl Store {
/// The root path at which we expect to find all objects and packs, and which is the source of the
/// alternate file traversal in case there are linked repositories.
pub fn path(&self) -> &std::path::Path {
&self.path
}
/// The kind of object hash to assume when dealing with pack indices and pack data files.
pub fn object_hash(&self) -> gix_hash::Kind {
self.object_hash
}
/// Whether or not we are allowed to use multi-pack indices
pub fn use_multi_pack_index(&self) -> bool {
self.use_multi_pack_index
}
/// An iterator over replacements from object-ids `X` to `X-replaced` as `(X, X-replaced)`, sorted by the original id `X`.
pub fn replacements(&self) -> impl Iterator<Item = (gix_hash::ObjectId, gix_hash::ObjectId)> + '_ {
self.replacements.iter().copied()
}
}

Some files were not shown because too many files have changed in this diff Show More