use crate::kernel::{FileKind, NamespacePath};
use crate::model::{CheckoutMode, GenerationSelector, VolumeLimits};
use crate::storage::ByteRange;
use crate::workspace::customer_path;
use crate::{
AsyncAuthorityStore, AsyncObjectStore, CancellationToken, Generation, GenerationId,
IdempotencyKey, OperationId, ResolvedFile, ResolvedFileRangeReadRequest, TransactionCommit,
WorkBudget, Workspace, WorkspaceError, WorkspaceId,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::sync::Mutex;
use thiserror::Error;
const STATE_VERSION: u32 = 9;
const COMMIT_DOMAIN: &[u8] = b"acyclic-fs-git-compat-commit-v1\0";
const ACTION_DOMAIN: &[u8] = b"acyclic-fs-git-compat-action-v1\0";
const MAXIMUM_CAS_ATTEMPTS: u8 = 32;
const GREP_READ_CONCURRENCY: usize = 32;
const MAXIMUM_PATCH_FILE_BYTES: u64 = 64 * 1024 * 1024;
async fn resolved_git_regular_files<A: AsyncAuthorityStore, O: AsyncObjectStore>(
reader: &crate::PinnedReader<A, O>,
root_display: String,
root: NamespacePath,
limits: VolumeLimits,
maximum_entries: usize,
cancellation: &CancellationToken,
) -> Result<(Vec<(String, ResolvedFile<A, O>)>, bool), WorkspaceError> {
let mut pending = vec![(root_display, root)];
let mut regular = Vec::new();
let mut visited = 0_usize;
let mut truncated = false;
'walk: while let Some((directory_display, directory)) = pending.pop() {
let mut after = None;
loop {
let page = reader
.resolve_directory_page(
&directory,
after.as_ref(),
1_024,
WorkBudget::UNBOUNDED,
cancellation,
)
.await
.map_err(WorkspaceError::engine)?
.value;
let has_more = page.has_more;
after = page.entries.last().map(|entry| entry.name.clone());
for entry in page.entries {
if visited >= maximum_entries {
truncated = true;
break 'walk;
}
visited = visited.saturating_add(1);
let name = entry
.name
.unicode_text()
.ok_or_else(|| WorkspaceError::path("non-portable Git path"))?;
let path = if directory_display.is_empty() {
name.to_string()
} else {
format!("{directory_display}/{name}")
};
if entry.file.description().kind == FileKind::Directory {
let mut components = directory.components().to_vec();
components.push(entry.name);
let child = NamespacePath::new(components, limits)
.map_err(|error| WorkspaceError::path(error.to_string()))?;
pending.push((path, child));
} else if entry.file.description().kind == FileKind::Regular {
regular.push((path, entry.file));
}
}
if !has_more {
break;
}
}
}
regular.sort_by(|left, right| left.0.cmp(&right.0));
Ok((regular, truncated))
}
async fn resolved_git_grep_files<A: AsyncAuthorityStore, O: AsyncObjectStore>(
reader: &crate::PinnedReader<A, O>,
root_display: String,
root: NamespacePath,
limits: VolumeLimits,
maximum_entries: usize,
cancellation: &CancellationToken,
) -> Result<(Vec<(String, ResolvedFile<A, O>)>, bool), WorkspaceError> {
if root_display.is_empty() {
return resolved_git_regular_files(
reader,
root_display,
root,
limits,
maximum_entries,
cancellation,
)
.await;
}
let mut roots = reader
.resolve_files(
std::slice::from_ref(&root),
WorkBudget::UNBOUNDED,
cancellation,
)
.await
.map_err(WorkspaceError::engine)?
.value;
let root_file = roots.pop().flatten().ok_or(WorkspaceError::NotFound)?;
match root_file.description().kind {
FileKind::Directory => {
resolved_git_regular_files(
reader,
root_display,
root,
limits,
maximum_entries,
cancellation,
)
.await
}
FileKind::Regular => Ok((vec![(root_display, root_file)], false)),
_ => Ok((Vec::new(), false)),
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitCommitId([u8; 32]);
impl GitCommitId {
#[must_use]
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
#[must_use]
pub const fn into_bytes(self) -> [u8; 32] {
self.0
}
#[must_use]
pub fn to_hex(self) -> String {
hex::encode(self.0)
}
}
impl Serialize for GitCommitId {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_hex())
}
}
impl<'de> Deserialize<'de> for GitCommitId {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
let bytes = hex::decode(&value).map_err(serde::de::Error::custom)?;
let bytes: [u8; 32] = bytes.try_into().map_err(|bytes: Vec<u8>| {
serde::de::Error::custom(format!(
"Git compatibility commit ID has {} bytes instead of 32",
bytes.len()
))
})?;
Ok(Self(bytes))
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitCaptureProof {
fork_parent: GitTreeRef,
initial_generation: crate::GenerationId,
operation_id: OperationId,
}
impl GitCaptureProof {
fn structurally_valid(
&self,
tree: GitTreeRef,
workspace_tree: GitTreeRef,
previous_tree: Option<GitTreeRef>,
) -> bool {
let GitTreeRef::Exact(tree) = tree else {
return false;
};
let GitTreeRef::Exact(parent) = self.fork_parent else {
return false;
};
tree.workspace_id != workspace_tree.workspace_id()
&& tree.generation != self.initial_generation
&& (self.fork_parent == workspace_tree || previous_tree == Some(self.fork_parent))
&& parent.workspace_id != tree.workspace_id
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitCommit {
pub id: GitCommitId,
pub tree: GitTreeRef,
pub workspace_tree: GitTreeRef,
#[serde(default)]
pub capture_proof: Option<GitCaptureProof>,
#[serde(default)]
pub tracked_paths: BTreeSet<String>,
pub parents: Vec<GitCommitId>,
pub author: String,
pub authored_at_seconds: i64,
pub message: String,
}
impl GitCommit {
#[must_use]
pub fn new(
tree: GitTreeRef,
parents: Vec<GitCommitId>,
author: impl Into<String>,
authored_at_seconds: i64,
message: impl Into<String>,
) -> Self {
Self::new_with_workspace_tree(tree, tree, parents, author, authored_at_seconds, message)
}
fn new_with_workspace_tree(
tree: GitTreeRef,
workspace_tree: GitTreeRef,
parents: Vec<GitCommitId>,
author: impl Into<String>,
authored_at_seconds: i64,
message: impl Into<String>,
) -> Self {
Self::new_with_metadata(
tree,
workspace_tree,
BTreeSet::new(),
parents,
author,
authored_at_seconds,
message,
)
}
fn new_with_metadata(
tree: GitTreeRef,
workspace_tree: GitTreeRef,
tracked_paths: BTreeSet<String>,
parents: Vec<GitCommitId>,
author: impl Into<String>,
authored_at_seconds: i64,
message: impl Into<String>,
) -> Self {
Self::new_with_capture_proof(
tree,
workspace_tree,
None,
tracked_paths,
parents,
author,
authored_at_seconds,
message,
)
}
#[allow(clippy::too_many_arguments)]
fn new_with_capture_proof(
tree: GitTreeRef,
workspace_tree: GitTreeRef,
capture_proof: Option<GitCaptureProof>,
tracked_paths: BTreeSet<String>,
parents: Vec<GitCommitId>,
author: impl Into<String>,
authored_at_seconds: i64,
message: impl Into<String>,
) -> Self {
let author = author.into();
let message = message.into();
let mut hasher = blake3::Hasher::new();
hasher.update(COMMIT_DOMAIN);
hash_tree_ref(&mut hasher, tree);
hash_tree_ref(&mut hasher, workspace_tree);
if let Some(proof) = &capture_proof {
hasher.update(&[1]);
hash_tree_ref(&mut hasher, proof.fork_parent);
hasher.update(proof.initial_generation.digest().as_bytes());
hasher.update(&proof.operation_id.into_bytes());
}
hasher.update(
&u64::try_from(tracked_paths.len())
.unwrap_or(u64::MAX)
.to_le_bytes(),
);
for path in &tracked_paths {
hash_bytes(&mut hasher, path.as_bytes());
}
hasher.update(
&u64::try_from(parents.len())
.unwrap_or(u64::MAX)
.to_le_bytes(),
);
for parent in &parents {
hasher.update(&parent.0);
}
hash_bytes(&mut hasher, author.as_bytes());
hasher.update(&authored_at_seconds.to_le_bytes());
hash_bytes(&mut hasher, message.as_bytes());
Self {
id: GitCommitId(*hasher.finalize().as_bytes()),
tree,
workspace_tree,
capture_proof,
tracked_paths,
parents,
author,
authored_at_seconds,
message,
}
}
}
fn hash_tree_ref(hasher: &mut blake3::Hasher, tree: GitTreeRef) {
match tree {
GitTreeRef::Exact(reference) => {
hasher.update(&[0]);
hasher.update(&reference.workspace_id.into_bytes());
hasher.update(reference.generation.digest().as_bytes());
}
GitTreeRef::Lazy(reference) => {
hasher.update(&[1]);
hasher.update(&reference.id.into_bytes());
}
}
}
fn hash_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
hasher.update(&u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes());
hasher.update(bytes);
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitBranch {
pub name: String,
pub workspace_id: WorkspaceId,
pub head: Option<GitCommitId>,
#[serde(default)]
pub tracked_paths: BTreeSet<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitGenerationRef {
pub workspace_id: WorkspaceId,
pub generation: GenerationId,
}
impl From<GitGenerationRef> for GitTreeRef {
fn from(value: GitGenerationRef) -> Self {
Self::Exact(value)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum GitTreeRef {
Exact(GitGenerationRef),
Lazy(crate::LazySnapshotRef),
}
impl GitTreeRef {
#[must_use]
pub const fn exact(workspace_id: WorkspaceId, generation: GenerationId) -> Self {
Self::Exact(GitGenerationRef {
workspace_id,
generation,
})
}
#[must_use]
pub const fn workspace_id(self) -> WorkspaceId {
match self {
Self::Exact(reference) => reference.workspace_id,
Self::Lazy(reference) => reference.workspace_id,
}
}
#[must_use]
pub const fn authored_generation(self) -> GenerationId {
match self {
Self::Exact(reference) => reference.generation,
Self::Lazy(reference) => reference.authored_generation,
}
}
}
pub trait IntoGitTreeRef {
fn into_git_tree_ref(self, repository_workspace: WorkspaceId) -> GitTreeRef;
}
impl IntoGitTreeRef for GitTreeRef {
fn into_git_tree_ref(self, _repository_workspace: WorkspaceId) -> GitTreeRef {
self
}
}
impl IntoGitTreeRef for GenerationId {
fn into_git_tree_ref(self, repository_workspace: WorkspaceId) -> GitTreeRef {
GitTreeRef::exact(repository_workspace, self)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitCompatState {
pub version: u32,
pub revision: u64,
pub current_branch: String,
pub branches: BTreeMap<String, GitBranch>,
pub commits: BTreeMap<GitCommitId, GitCommit>,
#[serde(default)]
pub external_parents: BTreeSet<GitCommitId>,
pub tags: BTreeMap<String, GitCommitId>,
pub reflog: Vec<Option<GitCommitId>>,
pub stash: Vec<GitTreeRef>,
#[serde(
default,
rename = "tracked_paths",
skip_serializing_if = "BTreeSet::is_empty"
)]
pub legacy_tracked_paths: BTreeSet<String>,
#[serde(default)]
pub pending: Option<GitPendingTransition>,
#[serde(default)]
pub bisect: Option<GitBisectState>,
}
impl GitCompatState {
#[must_use]
pub fn new(branch: impl Into<String>, workspace_id: WorkspaceId) -> Self {
let branch = branch.into();
let mut branches = BTreeMap::new();
branches.insert(
branch.clone(),
GitBranch {
name: branch.clone(),
workspace_id,
head: None,
tracked_paths: BTreeSet::new(),
},
);
Self {
version: STATE_VERSION,
revision: 0,
current_branch: branch,
branches,
commits: BTreeMap::new(),
external_parents: BTreeSet::new(),
tags: BTreeMap::new(),
reflog: Vec::new(),
stash: Vec::new(),
legacy_tracked_paths: BTreeSet::new(),
pending: None,
bisect: None,
}
}
fn current(&self) -> Result<&GitBranch, GitCompatStateError> {
self.branches
.get(&self.current_branch)
.ok_or(GitCompatStateError::Invalid)
}
fn current_mut(&mut self) -> Result<&mut GitBranch, GitCompatStateError> {
self.branches
.get_mut(&self.current_branch)
.ok_or(GitCompatStateError::Invalid)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(transparent)]
pub struct GitTransitionId(OperationId);
impl GitTransitionId {
#[must_use]
pub fn new() -> Self {
Self(OperationId::new())
}
#[must_use]
pub const fn from_bytes(bytes: [u8; 16]) -> Self {
Self(OperationId::from_bytes(bytes))
}
#[must_use]
pub const fn into_bytes(self) -> [u8; 16] {
self.0.into_bytes()
}
#[must_use]
pub const fn operation_id(self) -> OperationId {
self.0
}
}
impl Default for GitTransitionId {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum GitPendingMutation {
NoOp,
CaptureCommit {
workspace_tree: GitTreeRef,
message: String,
author: String,
authored_at_seconds: i64,
expected_head: Option<GitCommitId>,
},
ForkBranch {
branch: String,
head: Option<GitCommitId>,
switch: bool,
},
Switch {
branch: String,
},
Reset {
head: GitCommitId,
},
StashPush {
tree: GitTreeRef,
},
StashPop {
tree: GitTreeRef,
},
Join {
source_head: Option<GitCommitId>,
source_branch: String,
rebase: bool,
},
ApplyCommit {
commit: GitCommitId,
reverse: bool,
},
Bisect {
state: Box<Option<GitBisectState>>,
result: GitBisectResult,
},
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitBisectState {
pub original_branch: String,
pub original_head: GitCommitId,
pub good: Option<GitCommitId>,
pub bad: GitCommitId,
pub current: Option<GitCommitId>,
pub skipped: BTreeSet<GitCommitId>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitBisectResult {
pub active: bool,
pub good: Option<GitCommitId>,
pub bad: Option<GitCommitId>,
pub current: Option<GitCommitId>,
pub remaining: u32,
pub first_bad: Option<GitCommitId>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitPendingTransition {
pub id: GitTransitionId,
pub action: GitFilesystemAction,
pub mutation: GitPendingMutation,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitObjectName(pub String);
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum GitResetMode {
Soft,
Mixed,
Hard,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum GitCommand {
Status,
Diff {
cached: bool,
},
Log {
maximum: u32,
},
Show {
object: Option<GitObjectName>,
},
Add {
paths: Vec<String>,
},
Commit {
message: String,
author: String,
authored_at_seconds: i64,
},
Branch {
create: Option<String>,
},
Switch {
branch: String,
create: bool,
},
Restore {
source: Option<GitObjectName>,
paths: Vec<String>,
},
Reset {
target: GitObjectName,
mode: GitResetMode,
},
Merge {
branch: String,
},
MergeContinue,
MergeAbort,
Rebase {
branch: String,
},
StashPush,
StashPop,
CherryPick {
object: GitObjectName,
},
Revert {
object: GitObjectName,
},
Tag {
name: Option<String>,
target: Option<GitObjectName>,
delete: bool,
},
Blame {
path: String,
},
Grep {
pattern: String,
path: Option<String>,
},
Clean {
dry_run: bool,
},
Archive {
object: Option<GitObjectName>,
},
Apply {
patch: Vec<u8>,
},
Bisect {
arguments: Vec<String>,
},
RevParse {
argument: String,
},
SymbolicRef {
short: bool,
},
MergeBase {
left: GitObjectName,
right: GitObjectName,
},
LsFiles,
CheckIgnore {
paths: Vec<String>,
},
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum GitFilesystemAction {
CaptureCommit {
workspace_tree: GitTreeRef,
head_tree: Option<GitTreeRef>,
head_workspace_tree: Box<Option<GitTreeRef>>,
tracked_paths: BTreeSet<String>,
message: String,
author: String,
authored_at_seconds: i64,
expected_head: Option<GitCommitId>,
},
ForkBranch {
branch: String,
source_tree: GitTreeRef,
head: Option<GitCommitId>,
switch: bool,
},
SwitchWorkspace {
workspace_id: WorkspaceId,
},
Diff {
from: Option<GitTreeRef>,
to: GitTreeRef,
tracked_paths: BTreeSet<String>,
},
RestoreGeneration {
tree: GitTreeRef,
paths: Option<BTreeSet<String>>,
},
RestorePaths {
tree: GitTreeRef,
paths: Vec<String>,
},
Join {
target_tree: GitTreeRef,
source_workspace: WorkspaceId,
rebase: bool,
tracked_paths: BTreeSet<String>,
},
ApplyCommit {
commit: GitCommitId,
reverse: bool,
base: Option<GitTreeRef>,
source: Option<GitTreeRef>,
paths: BTreeSet<String>,
tracked_paths: BTreeSet<String>,
},
Blame {
path: String,
commits: Vec<GitCommit>,
},
Grep {
pattern: String,
path: Option<String>,
tree: GitTreeRef,
},
Clean {
dry_run: bool,
tree: GitTreeRef,
tracked_paths: BTreeSet<String>,
},
Archive {
tree: GitTreeRef,
},
ApplyPatch {
patch: Vec<u8>,
},
CheckIgnore {
paths: Vec<String>,
tree: GitTreeRef,
},
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
#[allow(clippy::large_enum_variant)] pub enum GitFilesystemResult {
Captured {
tree: GitTreeRef,
tracked_paths: BTreeSet<String>,
proof: Option<GitCaptureProof>,
},
Forked {
workspace_id: WorkspaceId,
},
Applied {
tree: Option<GitTreeRef>,
tracked_paths: Option<BTreeSet<String>>,
},
Data {
kind: String,
value: serde_json::Value,
},
}
impl GitFilesystemResult {
fn resulting_tree(&self) -> Option<GitTreeRef> {
match self {
Self::Captured { tree, .. } => Some(*tree),
Self::Applied { tree, .. } => *tree,
Self::Forked { .. } | Self::Data { .. } => None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GitDirtyState {
Clean,
Dirty,
Unknown,
}
pub trait GitFilesystemExecutor: Send + Sync {
type Error: std::error::Error + Send + Sync + 'static;
fn validate(&self) -> impl Future<Output = Result<(), Self::Error>> + Send {
async { Ok(()) }
}
fn execute(
&self,
operation_id: OperationId,
action: &GitFilesystemAction,
) -> impl Future<Output = Result<GitFilesystemResult, Self::Error>> + Send;
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitStatus {
pub branch: String,
pub head: Option<GitCommitId>,
pub workspace: GitTreeRef,
pub dirty: GitDirtyState,
pub all_changes_staged: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
#[allow(clippy::large_enum_variant)] pub enum GitCommandOutput {
NoOp,
Status(GitStatus),
Commits(Vec<GitCommit>),
Branches {
current: String,
branches: Vec<GitBranch>,
},
Tags(BTreeMap<String, GitCommitId>),
Committed(GitCommit),
Bisect(GitBisectResult),
Action(GitFilesystemAction),
Prepared {
transition: GitTransitionId,
action: GitFilesystemAction,
},
Filesystem(GitFilesystemResult),
Text(String),
Paths(Vec<String>),
}
pub trait GitCompatStore: Send + Sync {
type Error: std::error::Error + Send + Sync + 'static;
fn load(
&self,
workspace_id: WorkspaceId,
) -> impl Future<Output = Result<Option<GitCompatState>, Self::Error>> + Send;
fn compare_and_swap(
&self,
workspace_id: WorkspaceId,
expected_revision: u64,
replacement: GitCompatState,
) -> impl Future<Output = Result<bool, Self::Error>> + Send;
fn compare_and_delete(
&self,
workspace_id: WorkspaceId,
expected_revision: u64,
) -> impl Future<Output = Result<bool, Self::Error>> + Send;
}
#[derive(Debug, Error)]
enum GitCompatStateError {
#[error("invalid compatibility state")]
Invalid,
#[error("unknown object")]
Unknown(String),
#[error("branch already exists")]
BranchExists(String),
#[error("HEAD is unborn")]
UnbornHead,
#[error("nothing to commit")]
NothingToCommit,
#[error("stash is empty")]
EmptyStash,
#[error("invalid bisect operation: {0}")]
Bisect(String),
}
#[derive(Debug, Error)]
pub enum GitCompatError<E: std::error::Error + 'static> {
#[error("Git compatibility store failed: {0}")]
Store(E),
#[error("Git compatibility state is invalid")]
InvalidState,
#[error("Git compatibility live tree does not belong to the selected branch workspace")]
WorkspaceMismatch,
#[error("invalid Git-compatible command: {0}")]
InvalidCommand(String),
#[error("unknown Git-compatible object '{0}'")]
UnknownObject(String),
#[error("Git-compatible branch '{0}' already exists")]
BranchExists(String),
#[error("Git-compatible HEAD is unborn")]
UnbornHead,
#[error("nothing to commit")]
NothingToCommit,
#[error("unsupported Git command '{command}': {reason}")]
Unsupported {
command: String,
reason: String,
},
#[error("Git compatibility state remained contended")]
Contended,
#[error("Git compatibility transition {0:?} is still pending")]
TransitionPending(GitTransitionId),
#[error("Git compatibility transition is stale")]
StaleTransition,
#[error("Git compatibility transition requires a resulting generation")]
MissingResultGeneration,
}
#[derive(Debug, Error)]
pub enum GitCompatRunError<S: std::error::Error + 'static, E: std::error::Error + 'static> {
#[error(transparent)]
Compat(#[from] GitCompatError<S>),
#[error("Git compatibility filesystem action failed: {0}")]
Executor(E),
#[error("Git compatibility executor returned an invalid result")]
InvalidResult,
#[error("Git compatibility action encoding failed: {0}")]
Encoding(#[from] serde_json::Error),
}
pub struct GitCompatRepository<S> {
workspace_id: WorkspaceId,
store: S,
}
struct GitCommitRecord {
expected_head: Option<GitCommitId>,
tree: GitTreeRef,
workspace_tree: GitTreeRef,
tracked_paths: BTreeSet<String>,
capture_proof: Option<GitCaptureProof>,
message: String,
author: String,
authored_at_seconds: i64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitPublicationRecord {
pub tree: GitTreeRef,
pub workspace_tree: GitTreeRef,
pub source_head: Option<GitCommitId>,
pub tracked_paths: BTreeSet<String>,
pub capture_proof: Option<GitCaptureProof>,
pub message: String,
pub author: String,
pub authored_at_seconds: i64,
}
impl<S> GitCompatRepository<S> {
#[must_use]
pub const fn new(workspace_id: WorkspaceId, store: S) -> Self {
Self {
workspace_id,
store,
}
}
#[must_use]
pub const fn store(&self) -> &S {
&self.store
}
}
impl<S: GitCompatStore> GitCompatRepository<S> {
pub async fn tracked_paths(&self) -> Result<BTreeSet<String>, GitCompatError<S::Error>> {
self.load()
.await?
.current()
.map(|branch| branch.tracked_paths.clone())
.map_err(|_| GitCompatError::InvalidState)
}
pub async fn head(&self) -> Result<Option<GitCommitId>, GitCompatError<S::Error>> {
self.load()
.await?
.current()
.map(|branch| branch.head)
.map_err(|_| GitCompatError::InvalidState)
}
pub async fn run<E: GitFilesystemExecutor, T: IntoGitTreeRef>(
&self,
command: GitCommand,
workspace_tree: T,
executor: &E,
) -> Result<GitCommandOutput, GitCompatRunError<S::Error, E::Error>> {
let workspace_tree = workspace_tree.into_git_tree_ref(self.workspace_id);
self.validate_workspace_tree(workspace_tree).await?;
executor
.validate()
.await
.map_err(GitCompatRunError::Executor)?;
match &command {
GitCommand::MergeContinue => {
return self.continue_join(workspace_tree, executor).await;
}
GitCommand::MergeAbort => return self.abort_join(executor).await,
GitCommand::Add { .. } => {
if let Some(pending) = self.pending_transition().await? {
if matches!(pending.mutation, GitPendingMutation::Join { .. }) {
return Ok(GitCommandOutput::NoOp);
}
return Err(GitCompatError::TransitionPending(pending.id).into());
}
}
_ => {}
}
let output = self.execute_validated(command, workspace_tree).await?;
executor
.validate()
.await
.map_err(GitCompatRunError::Executor)?;
self.finish_output(output, workspace_tree, executor).await
}
async fn continue_join<E: GitFilesystemExecutor>(
&self,
workspace_tree: GitTreeRef,
executor: &E,
) -> Result<GitCommandOutput, GitCompatRunError<S::Error, E::Error>> {
let pending = self.pending_transition().await?.ok_or_else(|| {
GitCompatError::InvalidCommand("merge --continue requires a pending merge".to_owned())
})?;
if !matches!(pending.mutation, GitPendingMutation::Join { .. }) {
return Err(GitCompatError::TransitionPending(pending.id).into());
}
let GitFilesystemAction::Join { tracked_paths, .. } = &pending.action else {
return Err(GitCompatError::InvalidState.into());
};
executor
.validate()
.await
.map_err(GitCompatRunError::Executor)?;
self.complete_transition_result(
pending.id,
&GitFilesystemResult::Applied {
tree: Some(workspace_tree),
tracked_paths: Some(tracked_paths.clone()),
},
)
.await
.map_err(Into::into)
}
async fn abort_join<E: GitFilesystemExecutor>(
&self,
executor: &E,
) -> Result<GitCommandOutput, GitCompatRunError<S::Error, E::Error>> {
let pending = self.pending_transition().await?.ok_or_else(|| {
GitCompatError::InvalidCommand("merge --abort requires a pending merge".to_owned())
})?;
if !matches!(pending.mutation, GitPendingMutation::Join { .. }) {
return Err(GitCompatError::TransitionPending(pending.id).into());
}
let GitFilesystemAction::Join { target_tree, .. } = pending.action else {
return Err(GitCompatError::InvalidState.into());
};
let mut operation_bytes = Vec::with_capacity(21);
operation_bytes.extend_from_slice(&pending.id.into_bytes());
operation_bytes.extend_from_slice(b"abort");
let digest = blake3::hash(&operation_bytes);
let mut operation_id = [0_u8; 16];
operation_id.copy_from_slice(&digest.as_bytes()[..16]);
let result = executor
.execute(
OperationId::from_bytes(operation_id),
&GitFilesystemAction::RestoreGeneration {
tree: target_tree,
paths: None,
},
)
.await
.map_err(GitCompatRunError::Executor)?;
executor
.validate()
.await
.map_err(GitCompatRunError::Executor)?;
self.abort_transition(pending.id).await?;
Ok(GitCommandOutput::Filesystem(result))
}
pub async fn run_argv<E: GitFilesystemExecutor, T: IntoGitTreeRef>(
&self,
argv: &[String],
workspace_tree: T,
default_author: &str,
now_seconds: i64,
executor: &E,
) -> Result<GitCommandOutput, GitCompatRunError<S::Error, E::Error>> {
let command = parse_argv(argv, default_author, now_seconds)?;
self.run(command, workspace_tree, executor).await
}
pub async fn resume<E: GitFilesystemExecutor>(
&self,
executor: &E,
) -> Result<Option<GitCommandOutput>, GitCompatRunError<S::Error, E::Error>> {
let Some(pending) = self.pending_transition().await? else {
return Ok(None);
};
let result = executor
.execute(pending.id.operation_id(), &pending.action)
.await
.map_err(GitCompatRunError::Executor)?;
executor
.validate()
.await
.map_err(GitCompatRunError::Executor)?;
let output = self.complete_transition_result(pending.id, &result).await?;
Ok(Some(if matches!(output, GitCommandOutput::NoOp) {
GitCommandOutput::Filesystem(result)
} else {
output
}))
}
async fn finish_output<E: GitFilesystemExecutor>(
&self,
output: GitCommandOutput,
workspace_tree: GitTreeRef,
executor: &E,
) -> Result<GitCommandOutput, GitCompatRunError<S::Error, E::Error>> {
let (action, operation_id, transition) = match output {
GitCommandOutput::Action(action) => {
let operation_id = action_operation_id(self.workspace_id, workspace_tree, &action)?;
(action, operation_id, None)
}
GitCommandOutput::Prepared { transition, action } => {
(action, transition.operation_id(), Some(transition))
}
other => return Ok(other),
};
let result = executor
.execute(operation_id, &action)
.await
.map_err(GitCompatRunError::Executor)?;
executor
.validate()
.await
.map_err(GitCompatRunError::Executor)?;
if let Some(transition) = transition {
let completed = self.complete_transition_result(transition, &result).await?;
return Ok(if matches!(completed, GitCommandOutput::NoOp) {
GitCommandOutput::Filesystem(result)
} else {
completed
});
}
match (action, result) {
(
GitFilesystemAction::CaptureCommit {
workspace_tree,
message,
author,
authored_at_seconds,
expected_head,
..
},
GitFilesystemResult::Captured {
tree,
tracked_paths,
proof,
},
) => self
.record_captured_commit(GitCommitRecord {
expected_head,
tree,
workspace_tree,
tracked_paths,
capture_proof: proof,
message,
author,
authored_at_seconds,
})
.await
.map_err(Into::into),
(
GitFilesystemAction::ForkBranch {
branch,
head,
switch,
..
},
GitFilesystemResult::Forked { workspace_id },
) => self
.register_branch_workspace(branch, workspace_id, head, switch)
.await
.map_err(Into::into),
(GitFilesystemAction::CaptureCommit { .. }, _)
| (GitFilesystemAction::ForkBranch { .. }, _) => Err(GitCompatRunError::InvalidResult),
(_, result) => Ok(GitCommandOutput::Filesystem(result)),
}
}
pub async fn execute<T: IntoGitTreeRef>(
&self,
command: GitCommand,
workspace_tree: T,
) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
let workspace_tree = workspace_tree.into_git_tree_ref(self.workspace_id);
self.validate_workspace_tree(workspace_tree).await?;
self.execute_validated(command, workspace_tree).await
}
async fn execute_validated(
&self,
command: GitCommand,
workspace_tree: GitTreeRef,
) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
for _ in 0..MAXIMUM_CAS_ATTEMPTS {
let mut state = self.load().await?;
if let Some(pending) = &state.pending {
return Err(GitCompatError::TransitionPending(pending.id));
}
let before = state.clone();
let output = execute_command(&mut state, command.clone(), workspace_tree)
.map_err(map_state_error)?;
let output = match output {
GitCommandOutput::Action(action) => {
prepare_transition(&mut state, action, GitPendingMutation::NoOp)
}
output => output,
};
if state == before {
return Ok(output);
}
let expected = state.revision;
state.revision = expected.saturating_add(1);
if self.compare_and_swap_state(expected, state).await? {
return Ok(output);
}
}
Err(GitCompatError::Contended)
}
async fn validate_workspace_tree(
&self,
workspace_tree: GitTreeRef,
) -> Result<(), GitCompatError<S::Error>> {
let state = self.load().await?;
if state
.current()
.map_err(|_| GitCompatError::InvalidState)?
.workspace_id
!= workspace_tree.workspace_id()
{
return Err(GitCompatError::WorkspaceMismatch);
}
Ok(())
}
pub async fn pending_transition(
&self,
) -> Result<Option<GitPendingTransition>, GitCompatError<S::Error>> {
self.load().await.map(|state| state.pending)
}
pub async fn complete_transition(
&self,
transition: GitTransitionId,
resulting_tree: Option<GitTreeRef>,
) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
self.complete_transition_result(
transition,
&GitFilesystemResult::Applied {
tree: resulting_tree,
tracked_paths: None,
},
)
.await
}
pub async fn complete_transition_result(
&self,
transition: GitTransitionId,
result: &GitFilesystemResult,
) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
for _ in 0..MAXIMUM_CAS_ATTEMPTS {
let mut state = self.load().await?;
let pending = state
.pending
.clone()
.filter(|pending| pending.id == transition)
.ok_or(GitCompatError::StaleTransition)?;
validate_completion_result(&state, &pending, result)?;
let output = match complete_pending(&mut state, pending.mutation, result) {
Ok(output) => Some(output),
Err(GitCompatError::NothingToCommit) => None,
Err(error) => return Err(error),
};
state.pending = None;
let expected = state.revision;
state.revision = expected.saturating_add(1);
if self.compare_and_swap_state(expected, state).await? {
return output.ok_or(GitCompatError::NothingToCommit);
}
}
Err(GitCompatError::Contended)
}
pub async fn abort_transition(
&self,
transition: GitTransitionId,
) -> Result<(), GitCompatError<S::Error>> {
for _ in 0..MAXIMUM_CAS_ATTEMPTS {
let mut state = self.load().await?;
if state.pending.as_ref().map(|pending| pending.id) != Some(transition) {
return Err(GitCompatError::StaleTransition);
}
state.pending = None;
let expected = state.revision;
state.revision = expected.saturating_add(1);
if self.compare_and_swap_state(expected, state).await? {
return Ok(());
}
}
Err(GitCompatError::Contended)
}
pub async fn execute_argv<T: IntoGitTreeRef>(
&self,
argv: &[String],
workspace_tree: T,
default_author: &str,
now_seconds: i64,
) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
let command = parse_argv(argv, default_author, now_seconds)?;
self.execute(command, workspace_tree).await
}
pub async fn register_branch_workspace(
&self,
branch: impl Into<String>,
workspace_id: WorkspaceId,
head: Option<GitCommitId>,
switch: bool,
) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
let branch = branch.into();
for _ in 0..MAXIMUM_CAS_ATTEMPTS {
let mut state = self.load().await?;
let output = register_branch_workspace_state(
&mut state,
branch.clone(),
workspace_id,
head,
switch,
)?;
let expected = state.revision;
state.revision = expected.saturating_add(1);
if self.compare_and_swap_state(expected, state).await? {
return Ok(output);
}
}
Err(GitCompatError::Contended)
}
pub async fn record_commit(
&self,
expected_head: Option<GitCommitId>,
generation: GenerationId,
tracked_paths: BTreeSet<String>,
message: impl Into<String>,
author: impl Into<String>,
authored_at_seconds: i64,
) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
self.record_captured_commit(GitCommitRecord {
expected_head,
tree: GitGenerationRef {
workspace_id: self.workspace_id,
generation,
}
.into(),
workspace_tree: GitGenerationRef {
workspace_id: self.workspace_id,
generation,
}
.into(),
tracked_paths,
capture_proof: None,
message: message.into(),
author: author.into(),
authored_at_seconds,
})
.await
}
pub async fn record_publication(
&self,
publication: GitPublicationRecord,
) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
let GitPublicationRecord {
tree,
workspace_tree,
source_head,
tracked_paths,
capture_proof,
message,
author,
authored_at_seconds,
} = publication;
for _ in 0..MAXIMUM_CAS_ATTEMPTS {
let mut state = self.load().await?;
let current = state
.current()
.map_err(|_| GitCompatError::InvalidState)?
.clone();
if workspace_tree.workspace_id() != current.workspace_id {
return Err(GitCompatError::WorkspaceMismatch);
}
if current
.head
.and_then(|head| state.commits.get(&head))
.is_some_and(|commit| commit.tree == tree)
{
return Ok(GitCommandOutput::NoOp);
}
let mut parents = current.head.into_iter().collect::<Vec<_>>();
if let Some(source_head) = source_head
&& !parents.contains(&source_head)
{
if !state.commits.contains_key(&source_head) {
state.external_parents.insert(source_head);
}
parents.push(source_head);
}
let previous_tree = current
.head
.and_then(|id| state.commits.get(&id).map(|commit| commit.tree));
if !capture_proof_valid(tree, workspace_tree, capture_proof.as_ref(), previous_tree) {
return Err(GitCompatError::WorkspaceMismatch);
}
let commit = GitCommit::new_with_capture_proof(
tree,
workspace_tree,
capture_proof.clone(),
tracked_paths.clone(),
parents,
author.clone(),
authored_at_seconds,
message.clone(),
);
state.reflog.insert(0, current.head);
state.commits.insert(commit.id, commit.clone());
let branch = state
.current_mut()
.map_err(|_| GitCompatError::InvalidState)?;
branch.head = Some(commit.id);
branch.tracked_paths = tracked_paths.clone();
let expected = state.revision;
state.revision = expected.saturating_add(1);
if self.compare_and_swap_state(expected, state).await? {
return Ok(GitCommandOutput::Committed(commit));
}
}
Err(GitCompatError::Contended)
}
async fn record_captured_commit(
&self,
record: GitCommitRecord,
) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
let GitCommitRecord {
expected_head,
tree,
workspace_tree,
tracked_paths,
capture_proof,
message,
author,
authored_at_seconds,
} = record;
for _ in 0..MAXIMUM_CAS_ATTEMPTS {
let mut state = self.load().await?;
let output = record_captured_commit_state(
&mut state,
GitCommitRecord {
expected_head,
tree,
workspace_tree,
tracked_paths: tracked_paths.clone(),
capture_proof: capture_proof.clone(),
message: message.clone(),
author: author.clone(),
authored_at_seconds,
},
)?;
let expected = state.revision;
state.revision = expected.saturating_add(1);
if self.compare_and_swap_state(expected, state).await? {
return Ok(output);
}
}
Err(GitCompatError::Contended)
}
async fn load(&self) -> Result<GitCompatState, GitCompatError<S::Error>> {
let mut state = self
.store
.load(self.workspace_id)
.await
.map_err(GitCompatError::Store)?
.unwrap_or_else(|| GitCompatState::new("main", self.workspace_id));
if !(1..=STATE_VERSION).contains(&state.version) || state.current().is_err() {
return Err(GitCompatError::InvalidState);
}
if state.version < 6 && !state.legacy_tracked_paths.is_empty() {
let migrated = std::mem::take(&mut state.legacy_tracked_paths);
state
.current_mut()
.map_err(|_| GitCompatError::InvalidState)?
.tracked_paths = migrated;
}
state.version = STATE_VERSION;
validate_git_state(&state).map_err(|()| GitCompatError::InvalidState)?;
Ok(state)
}
async fn compare_and_swap_state(
&self,
expected: u64,
state: GitCompatState,
) -> Result<bool, GitCompatError<S::Error>> {
validate_git_state(&state).map_err(|()| GitCompatError::InvalidState)?;
self.store
.compare_and_swap(self.workspace_id, expected, state)
.await
.map_err(GitCompatError::Store)
}
}
fn validate_completion_result<E: std::error::Error + 'static>(
state: &GitCompatState,
pending: &GitPendingTransition,
result: &GitFilesystemResult,
) -> Result<(), GitCompatError<E>> {
let workspace_id = state
.current()
.map_err(|_| GitCompatError::InvalidState)?
.workspace_id;
match result {
GitFilesystemResult::Captured { tree, proof, .. } => {
let GitPendingMutation::CaptureCommit { workspace_tree, .. } = &pending.mutation else {
return Err(GitCompatError::WorkspaceMismatch);
};
let previous_tree = match &pending.mutation {
GitPendingMutation::CaptureCommit { expected_head, .. } => {
expected_head.and_then(|id| state.commits.get(&id).map(|commit| commit.tree))
}
_ => None,
};
let reused_head = proof.is_none() && previous_tree == Some(*tree);
let valid_capture =
capture_proof_valid(*tree, *workspace_tree, proof.as_ref(), previous_tree)
&& proof
.as_ref()
.is_none_or(|proof| proof.operation_id == pending.id.operation_id());
if !reused_head && !valid_capture {
return Err(GitCompatError::WorkspaceMismatch);
}
}
_ if result
.resulting_tree()
.is_some_and(|tree| tree.workspace_id() != workspace_id) =>
{
return Err(GitCompatError::WorkspaceMismatch);
}
_ => {}
}
if let GitPendingMutation::CaptureCommit { workspace_tree, .. } = pending.mutation
&& workspace_tree.workspace_id() != workspace_id
{
return Err(GitCompatError::WorkspaceMismatch);
}
Ok(())
}
fn capture_proof_valid(
tree: GitTreeRef,
workspace_tree: GitTreeRef,
proof: Option<&GitCaptureProof>,
previous_tree: Option<GitTreeRef>,
) -> bool {
if tree.workspace_id() == workspace_tree.workspace_id() {
return proof.is_none();
}
proof.is_some_and(|proof| proof.structurally_valid(tree, workspace_tree, previous_tree))
}
fn validate_git_state(state: &GitCompatState) -> Result<(), ()> {
if state.version != STATE_VERSION
|| state.branches.is_empty()
|| !state.branches.contains_key(&state.current_branch)
|| !state.legacy_tracked_paths.is_empty()
{
return Err(());
}
validate_git_branches(state)?;
let known_workspaces: BTreeSet<_> = state
.branches
.values()
.map(|branch| branch.workspace_id)
.collect();
validate_git_commits(state, &known_workspaces)?;
validate_git_commit_graph(state)?;
validate_git_references(state, &known_workspaces)
}
fn validate_git_branches(state: &GitCompatState) -> Result<(), ()> {
for (name, branch) in &state.branches {
let head_paths = match branch.head {
Some(head) => Some(&state.commits.get(&head).ok_or(())?.tracked_paths),
None => None,
};
if name.is_empty()
|| branch.name != *name
|| head_paths.map_or(!branch.tracked_paths.is_empty(), |tracked_paths| {
tracked_paths != &branch.tracked_paths
})
{
return Err(());
}
}
Ok(())
}
fn validate_git_commits(
state: &GitCompatState,
known_workspaces: &BTreeSet<WorkspaceId>,
) -> Result<(), ()> {
for (id, commit) in &state.commits {
let rebuilt = GitCommit::new_with_capture_proof(
commit.tree,
commit.workspace_tree,
commit.capture_proof.clone(),
commit.tracked_paths.clone(),
commit.parents.clone(),
commit.author.clone(),
commit.authored_at_seconds,
commit.message.clone(),
);
if *id != commit.id
|| rebuilt.id != commit.id
|| !known_workspaces.contains(&commit.workspace_tree.workspace_id())
|| !capture_proof_valid(
commit.tree,
commit.workspace_tree,
commit.capture_proof.as_ref(),
commit.parents.first().and_then(|id| state.commits.get(id).map(|parent| parent.tree)),
)
|| commit.parents.iter().any(|parent| {
!state.commits.contains_key(parent) && !state.external_parents.contains(parent)
})
{
return Err(());
}
}
Ok(())
}
fn validate_git_commit_graph(state: &GitCompatState) -> Result<(), ()> {
let mut permanent = BTreeSet::new();
for root in state.commits.keys().copied() {
if permanent.contains(&root) {
continue;
}
let mut active = BTreeSet::new();
let mut stack = vec![(root, false)];
while let Some((id, expanded)) = stack.pop() {
if expanded {
active.remove(&id);
permanent.insert(id);
continue;
}
if permanent.contains(&id) {
continue;
}
if !active.insert(id) {
return Err(());
}
stack.push((id, true));
let commit = state.commits.get(&id).ok_or(())?;
stack.extend(
commit
.parents
.iter()
.rev()
.filter(|parent| state.commits.contains_key(parent))
.map(|parent| (*parent, false)),
);
}
}
Ok(())
}
fn validate_git_references(
state: &GitCompatState,
known_workspaces: &BTreeSet<WorkspaceId>,
) -> Result<(), ()> {
let known_commit = |id: &GitCommitId| state.commits.contains_key(id);
let known_reference =
|id: &GitCommitId| state.commits.contains_key(id) || state.external_parents.contains(id);
if state.external_parents.iter().any(|external| {
state.commits.contains_key(external)
|| !state
.commits
.values()
.any(|commit| commit.parents.contains(external))
}) {
return Err(());
}
if state.tags.values().any(|id| !known_commit(id))
|| state.reflog.iter().flatten().any(|id| !known_commit(id))
|| state
.stash
.iter()
.any(|tree| !known_workspaces.contains(&tree.workspace_id()))
|| state.bisect.as_ref().is_some_and(|bisect| {
!known_commit(&bisect.original_head)
|| !known_commit(&bisect.bad)
|| bisect.good.as_ref().is_some_and(|id| !known_commit(id))
|| bisect.current.as_ref().is_some_and(|id| !known_commit(id))
|| bisect.skipped.iter().any(|id| !known_commit(id))
})
{
return Err(());
}
if let Some(pending) = &state.pending {
let valid = match &pending.mutation {
GitPendingMutation::CaptureCommit {
workspace_tree,
expected_head,
..
} => {
workspace_tree.workspace_id() == state.current().map_err(|_| ())?.workspace_id
&& expected_head.as_ref().is_none_or(known_commit)
}
GitPendingMutation::ForkBranch { branch, head, .. } => {
!branch.is_empty()
&& !state.branches.contains_key(branch)
&& head.as_ref().is_none_or(known_commit)
}
GitPendingMutation::Switch { branch } => state.branches.contains_key(branch),
GitPendingMutation::Reset { head }
| GitPendingMutation::ApplyCommit { commit: head, .. } => known_commit(head),
GitPendingMutation::Join { source_head, .. } => {
source_head.as_ref().is_none_or(known_reference)
}
GitPendingMutation::NoOp
| GitPendingMutation::StashPush { .. }
| GitPendingMutation::StashPop { .. }
| GitPendingMutation::Bisect { .. } => true,
};
if !valid {
return Err(());
}
}
Ok(())
}
fn action_operation_id(
workspace_id: WorkspaceId,
workspace_tree: GitTreeRef,
action: &GitFilesystemAction,
) -> Result<OperationId, serde_json::Error> {
let mut hasher = blake3::Hasher::new();
hasher.update(ACTION_DOMAIN);
hasher.update(&workspace_id.into_bytes());
hash_tree_ref(&mut hasher, workspace_tree);
let encoded = serde_json::to_vec(action)?;
hash_bytes(&mut hasher, &encoded);
let mut operation = [0; 16];
operation.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
Ok(OperationId::from_bytes(operation))
}
#[allow(clippy::too_many_lines)]
fn execute_command(
state: &mut GitCompatState,
command: GitCommand,
workspace: GitTreeRef,
) -> Result<GitCommandOutput, GitCompatStateError> {
let current = state.current()?.clone();
if state.bisect.is_some()
&& !matches!(
&command,
GitCommand::Status
| GitCommand::Diff { .. }
| GitCommand::Log { .. }
| GitCommand::Show { .. }
| GitCommand::RevParse { .. }
| GitCommand::SymbolicRef { .. }
| GitCommand::MergeBase { .. }
| GitCommand::LsFiles
| GitCommand::CheckIgnore { .. }
| GitCommand::Bisect { .. }
)
{
return Err(GitCompatStateError::Bisect(
"finish with 'git bisect reset' before changing history".to_owned(),
));
}
let visible_head = state
.bisect
.as_ref()
.and_then(|bisect| bisect.current)
.or(current.head);
let head_workspace_tree =
visible_head.and_then(|id| state.commits.get(&id).map(|commit| commit.workspace_tree));
let head_tree = visible_head.and_then(|id| state.commits.get(&id).map(|commit| commit.tree));
Ok(match command {
GitCommand::MergeContinue | GitCommand::MergeAbort => {
return Err(GitCompatStateError::Invalid);
}
GitCommand::Status => GitCommandOutput::Status(GitStatus {
branch: current.name,
head: visible_head,
workspace,
dirty: match head_workspace_tree {
Some(head) if head == workspace => GitDirtyState::Clean,
Some(GitTreeRef::Lazy(_)) | None if matches!(workspace, GitTreeRef::Lazy(_)) => {
GitDirtyState::Unknown
}
_ => GitDirtyState::Dirty,
},
all_changes_staged: true,
}),
GitCommand::Diff { .. } => GitCommandOutput::Action(GitFilesystemAction::Diff {
from: head_tree,
to: workspace,
tracked_paths: current.tracked_paths,
}),
GitCommand::Log { maximum } => {
GitCommandOutput::Commits(walk_commits(state, current.head, maximum))
}
GitCommand::Show { object } => {
let id = resolve_object(state, object.as_ref())?;
GitCommandOutput::Commits(vec![
state
.commits
.get(&id)
.cloned()
.ok_or(GitCompatStateError::Invalid)?,
])
}
GitCommand::Add { .. } => GitCommandOutput::NoOp,
GitCommand::Commit {
message,
author,
authored_at_seconds,
} => {
if head_workspace_tree == Some(workspace) {
return Err(GitCompatStateError::NothingToCommit);
}
let action = GitFilesystemAction::CaptureCommit {
workspace_tree: workspace,
head_tree,
head_workspace_tree: Box::new(head_workspace_tree),
tracked_paths: current.tracked_paths.clone(),
message: message.clone(),
author: author.clone(),
authored_at_seconds,
expected_head: current.head,
};
prepare_transition(
state,
action,
GitPendingMutation::CaptureCommit {
workspace_tree: workspace,
message,
author,
authored_at_seconds,
expected_head: current.head,
},
)
}
GitCommand::Branch { create } => {
if let Some(name) = create {
if state.branches.contains_key(&name) {
return Err(GitCompatStateError::BranchExists(name));
}
let action = GitFilesystemAction::ForkBranch {
branch: name.clone(),
source_tree: workspace,
head: current.head,
switch: false,
};
return Ok(prepare_transition(
state,
action,
GitPendingMutation::ForkBranch {
branch: name,
head: current.head,
switch: false,
},
));
}
GitCommandOutput::Branches {
current: state.current_branch.clone(),
branches: state.branches.values().cloned().collect(),
}
}
GitCommand::Switch { branch, create } => {
if create && !state.branches.contains_key(&branch) {
let action = GitFilesystemAction::ForkBranch {
branch: branch.clone(),
source_tree: workspace,
head: current.head,
switch: true,
};
return Ok(prepare_transition(
state,
action,
GitPendingMutation::ForkBranch {
branch,
head: current.head,
switch: true,
},
));
}
let target = state
.branches
.get(&branch)
.ok_or_else(|| GitCompatStateError::Unknown(branch.clone()))?;
prepare_transition(
state,
GitFilesystemAction::SwitchWorkspace {
workspace_id: target.workspace_id,
},
GitPendingMutation::Switch { branch },
)
}
GitCommand::Restore { source, paths } => {
let id = resolve_object(state, source.as_ref())?;
let commit = state.commits.get(&id).ok_or(GitCompatStateError::Invalid)?;
GitCommandOutput::Action(GitFilesystemAction::RestorePaths {
tree: commit.tree,
paths: expand_pathspecs(&paths, &commit.tracked_paths),
})
}
GitCommand::Reset { target, mode } => {
let id = resolve_object(state, Some(&target))?;
let commit = state.commits.get(&id).ok_or(GitCompatStateError::Invalid)?;
let tree = commit.tree;
let target_tracked_paths = commit.tracked_paths.clone();
match mode {
GitResetMode::Soft | GitResetMode::Mixed => {
state.reflog.insert(0, current.head);
let branch = state.current_mut()?;
branch.head = Some(id);
branch.tracked_paths = target_tracked_paths;
GitCommandOutput::NoOp
}
GitResetMode::Hard => prepare_transition(
state,
GitFilesystemAction::RestoreGeneration {
tree,
paths: Some(
current
.tracked_paths
.union(&target_tracked_paths)
.cloned()
.collect(),
),
},
GitPendingMutation::Reset { head: id },
),
}
}
GitCommand::Merge { branch } => {
let source = state
.branches
.get(&branch)
.ok_or_else(|| GitCompatStateError::Unknown(branch.clone()))?;
let tracked_paths = current
.tracked_paths
.union(&source.tracked_paths)
.cloned()
.collect();
prepare_transition(
state,
GitFilesystemAction::Join {
target_tree: workspace,
source_workspace: source.workspace_id,
rebase: false,
tracked_paths,
},
GitPendingMutation::Join {
source_head: source.head,
source_branch: branch,
rebase: false,
},
)
}
GitCommand::Rebase { branch } => {
let source = state
.branches
.get(&branch)
.ok_or_else(|| GitCompatStateError::Unknown(branch.clone()))?;
let tracked_paths = current
.tracked_paths
.union(&source.tracked_paths)
.cloned()
.collect();
prepare_transition(
state,
GitFilesystemAction::Join {
target_tree: workspace,
source_workspace: source.workspace_id,
rebase: true,
tracked_paths,
},
GitPendingMutation::Join {
source_head: source.head,
source_branch: branch,
rebase: true,
},
)
}
GitCommand::StashPush => {
let tree = head_workspace_tree.ok_or(GitCompatStateError::UnbornHead)?;
prepare_transition(
state,
GitFilesystemAction::RestoreGeneration { tree, paths: None },
GitPendingMutation::StashPush { tree: workspace },
)
}
GitCommand::StashPop => {
let tree = state
.stash
.last()
.copied()
.ok_or(GitCompatStateError::EmptyStash)?;
prepare_transition(
state,
GitFilesystemAction::RestoreGeneration { tree, paths: None },
GitPendingMutation::StashPop { tree },
)
}
GitCommand::CherryPick { object } => {
let commit = resolve_object(state, Some(&object))?;
let record = state
.commits
.get(&commit)
.ok_or(GitCompatStateError::Invalid)?;
let parent = record
.parents
.first()
.and_then(|parent| state.commits.get(parent));
let base = parent.map(|commit| commit_generation_ref(commit, current.workspace_id));
let source = Some(commit_generation_ref(record, current.workspace_id));
let paths = parent.map_or_else(
|| record.tracked_paths.clone(),
|parent| {
parent
.tracked_paths
.union(&record.tracked_paths)
.cloned()
.collect()
},
);
let tracked_paths = current.tracked_paths.union(&paths).cloned().collect();
prepare_transition(
state,
GitFilesystemAction::ApplyCommit {
commit,
reverse: false,
base,
source,
paths,
tracked_paths,
},
GitPendingMutation::ApplyCommit {
commit,
reverse: false,
},
)
}
GitCommand::Revert { object } => {
let commit = resolve_object(state, Some(&object))?;
let record = state
.commits
.get(&commit)
.ok_or(GitCompatStateError::Invalid)?;
let parent = record
.parents
.first()
.and_then(|parent| state.commits.get(parent));
let base = Some(commit_generation_ref(record, current.workspace_id));
let source = parent.map(|commit| commit_generation_ref(commit, current.workspace_id));
let paths = parent.map_or_else(
|| record.tracked_paths.clone(),
|parent| {
parent
.tracked_paths
.union(&record.tracked_paths)
.cloned()
.collect()
},
);
let tracked_paths = current.tracked_paths.union(&paths).cloned().collect();
prepare_transition(
state,
GitFilesystemAction::ApplyCommit {
commit,
reverse: true,
base,
source,
paths,
tracked_paths,
},
GitPendingMutation::ApplyCommit {
commit,
reverse: true,
},
)
}
GitCommand::Tag {
name,
target,
delete,
} => {
if let Some(name) = name {
if delete {
state.tags.remove(&name);
} else {
let id = resolve_object(state, target.as_ref())?;
state.tags.insert(name, id);
}
}
GitCommandOutput::Tags(state.tags.clone())
}
GitCommand::Blame { path } => GitCommandOutput::Action(GitFilesystemAction::Blame {
path,
commits: walk_commits(state, current.head, u32::MAX),
}),
GitCommand::Grep { pattern, path } => GitCommandOutput::Action(GitFilesystemAction::Grep {
pattern,
path,
tree: workspace,
}),
GitCommand::Clean { dry_run } => GitCommandOutput::Action(GitFilesystemAction::Clean {
dry_run,
tree: workspace,
tracked_paths: current.tracked_paths,
}),
GitCommand::Archive { object } => {
let snapshot = match object {
Some(object) => {
let id = resolve_object(state, Some(&object))?;
let commit = state.commits.get(&id).ok_or(GitCompatStateError::Invalid)?;
commit.tree
}
None => workspace,
};
GitCommandOutput::Action(GitFilesystemAction::Archive { tree: snapshot })
}
GitCommand::Apply { patch } => {
GitCommandOutput::Action(GitFilesystemAction::ApplyPatch { patch })
}
GitCommand::Bisect { arguments } => execute_bisect(state, ¤t, workspace, &arguments)?,
GitCommand::RevParse { argument } => {
let value = match argument.as_str() {
"--is-inside-work-tree" => "true".to_owned(),
"--abbrev-ref HEAD" => current.name,
"--symbolic-full-name HEAD" => format!("refs/heads/{}", current.name),
_ => resolve_object(state, Some(&GitObjectName(argument)))?.to_hex(),
};
GitCommandOutput::Text(value)
}
GitCommand::SymbolicRef { short } => GitCommandOutput::Text(if short {
current.name
} else {
format!("refs/heads/{}", current.name)
}),
GitCommand::MergeBase { left, right } => {
let left = resolve_object(state, Some(&left))?;
let right = resolve_object(state, Some(&right))?;
let common = nearest_common_ancestor(state, left, right)
.ok_or_else(|| GitCompatStateError::Unknown("merge base".to_owned()))?;
GitCommandOutput::Text(common.to_hex())
}
GitCommand::LsFiles => GitCommandOutput::Paths(current.tracked_paths.into_iter().collect()),
GitCommand::CheckIgnore { paths } => {
GitCommandOutput::Action(GitFilesystemAction::CheckIgnore {
paths,
tree: workspace,
})
}
})
}
fn nearest_common_ancestor(
state: &GitCompatState,
left: GitCommitId,
right: GitCommitId,
) -> Option<GitCommitId> {
fn distances(state: &GitCompatState, start: GitCommitId) -> BTreeMap<GitCommitId, u32> {
let mut result = BTreeMap::new();
let mut pending = std::collections::VecDeque::from([(start, 0_u32)]);
while let Some((commit, distance)) = pending.pop_front() {
if result.get(&commit).is_some_and(|known| *known <= distance) {
continue;
}
result.insert(commit, distance);
if let Some(record) = state.commits.get(&commit) {
pending.extend(
record
.parents
.iter()
.copied()
.map(|parent| (parent, distance.saturating_add(1))),
);
}
}
result
}
let left_distances = distances(state, left);
let right_distances = distances(state, right);
left_distances
.iter()
.filter_map(|(commit, left_distance)| {
right_distances
.get(commit)
.map(|right_distance| (*commit, left_distance + right_distance, *left_distance))
})
.min_by_key(|(commit, total, left_distance)| (*total, *left_distance, *commit))
.map(|(commit, _, _)| commit)
}
fn prepare_transition(
state: &mut GitCompatState,
action: GitFilesystemAction,
mutation: GitPendingMutation,
) -> GitCommandOutput {
let transition = GitTransitionId::new();
state.pending = Some(GitPendingTransition {
id: transition,
action: action.clone(),
mutation,
});
GitCommandOutput::Prepared { transition, action }
}
fn register_branch_workspace_state<E: std::error::Error + 'static>(
state: &mut GitCompatState,
branch: String,
workspace_id: WorkspaceId,
head: Option<GitCommitId>,
switch: bool,
) -> Result<GitCommandOutput, GitCompatError<E>> {
if let Some(existing) = state.branches.get(&branch) {
if existing.workspace_id != workspace_id || existing.head != head {
return Err(GitCompatError::BranchExists(branch));
}
if switch {
state.current_branch = branch;
}
return Ok(GitCommandOutput::NoOp);
}
let tracked_paths = head
.and_then(|head| state.commits.get(&head))
.map(|commit| commit.tracked_paths.clone())
.unwrap_or_default();
state.branches.insert(
branch.clone(),
GitBranch {
name: branch.clone(),
workspace_id,
head,
tracked_paths,
},
);
if switch {
state.current_branch = branch;
}
Ok(GitCommandOutput::NoOp)
}
fn record_captured_commit_state<E: std::error::Error + 'static>(
state: &mut GitCompatState,
record: GitCommitRecord,
) -> Result<GitCommandOutput, GitCompatError<E>> {
let current = state
.current()
.map_err(|_| GitCompatError::InvalidState)?
.clone();
if current.head != record.expected_head {
return Err(GitCompatError::InvalidState);
}
let head_tree = current
.head
.and_then(|id| state.commits.get(&id).map(|commit| commit.tree));
if head_tree == Some(record.tree) {
return Err(GitCompatError::NothingToCommit);
}
if !capture_proof_valid(
record.tree,
record.workspace_tree,
record.capture_proof.as_ref(),
head_tree,
) {
return Err(GitCompatError::WorkspaceMismatch);
}
let commit = GitCommit::new_with_capture_proof(
record.tree,
record.workspace_tree,
record.capture_proof,
record.tracked_paths.clone(),
current.head.into_iter().collect(),
record.author,
record.authored_at_seconds,
record.message,
);
state.reflog.insert(0, current.head);
state.commits.insert(commit.id, commit.clone());
let current = state
.current_mut()
.map_err(|_| GitCompatError::InvalidState)?;
current.head = Some(commit.id);
current.tracked_paths = record.tracked_paths;
Ok(GitCommandOutput::Committed(commit))
}
#[allow(clippy::too_many_lines)]
fn complete_pending<E: std::error::Error + 'static>(
state: &mut GitCompatState,
mutation: GitPendingMutation,
result: &GitFilesystemResult,
) -> Result<GitCommandOutput, GitCompatError<E>> {
let resulting_tree = result.resulting_tree();
match mutation {
GitPendingMutation::NoOp => Ok(GitCommandOutput::NoOp),
mutation @ (GitPendingMutation::CaptureCommit { .. }
| GitPendingMutation::ForkBranch { .. }) => {
complete_creation_pending(state, mutation, result)
}
GitPendingMutation::Switch { branch } => {
if !state.branches.contains_key(&branch) {
return Err(GitCompatError::InvalidState);
}
state.current_branch = branch;
Ok(GitCommandOutput::NoOp)
}
GitPendingMutation::Reset { head } => {
let previous = state
.current()
.map_err(|_| GitCompatError::InvalidState)?
.head;
state.reflog.insert(0, previous);
let tracked_paths = state
.commits
.get(&head)
.ok_or(GitCompatError::InvalidState)?
.tracked_paths
.clone();
let branch = state
.current_mut()
.map_err(|_| GitCompatError::InvalidState)?;
branch.head = Some(head);
branch.tracked_paths = tracked_paths;
Ok(GitCommandOutput::NoOp)
}
GitPendingMutation::StashPush { tree } => {
state.stash.push(tree);
Ok(GitCommandOutput::NoOp)
}
GitPendingMutation::StashPop { tree } => {
if state.stash.last() != Some(&tree) {
return Err(GitCompatError::InvalidState);
}
state.stash.pop();
Ok(GitCommandOutput::NoOp)
}
GitPendingMutation::Join {
source_head,
source_branch,
rebase,
} => {
let tree = resulting_tree.ok_or(GitCompatError::MissingResultGeneration)?;
let tracked_paths = match result {
GitFilesystemResult::Applied {
tracked_paths: Some(paths),
..
} => paths.clone(),
_ => return Err(GitCompatError::InvalidState),
};
let previous = state
.current()
.map_err(|_| GitCompatError::InvalidState)?
.head;
let parents = if rebase {
source_head.into_iter().collect()
} else {
previous.into_iter().chain(source_head).collect()
};
let verb = if rebase { "rebase" } else { "merge" };
record_generated_commit(
state,
tree,
parents,
tracked_paths,
format!("{verb} {source_branch}"),
previous,
)
}
GitPendingMutation::ApplyCommit { commit, reverse } => {
let tree = resulting_tree.ok_or(GitCompatError::MissingResultGeneration)?;
let tracked_paths = match result {
GitFilesystemResult::Applied {
tracked_paths: Some(paths),
..
} => paths.clone(),
_ => return Err(GitCompatError::InvalidState),
};
let previous = state
.current()
.map_err(|_| GitCompatError::InvalidState)?
.head;
let verb = if reverse { "revert" } else { "cherry-pick" };
record_generated_commit(
state,
tree,
previous.into_iter().collect(),
tracked_paths,
format!("{verb} {}", commit.to_hex()),
previous,
)
}
GitPendingMutation::Bisect {
state: replacement,
result,
} => {
state.bisect = *replacement;
Ok(GitCommandOutput::Bisect(result))
}
}
}
fn complete_creation_pending<E: std::error::Error + 'static>(
state: &mut GitCompatState,
mutation: GitPendingMutation,
result: &GitFilesystemResult,
) -> Result<GitCommandOutput, GitCompatError<E>> {
match (mutation, result) {
(
GitPendingMutation::CaptureCommit {
workspace_tree,
message,
author,
authored_at_seconds,
expected_head,
},
GitFilesystemResult::Captured {
tree,
tracked_paths,
proof,
},
) => record_captured_commit_state(
state,
GitCommitRecord {
expected_head,
tree: *tree,
workspace_tree,
tracked_paths: tracked_paths.clone(),
capture_proof: proof.clone(),
message,
author,
authored_at_seconds,
},
),
(
GitPendingMutation::ForkBranch {
branch,
head,
switch,
},
GitFilesystemResult::Forked { workspace_id },
) => register_branch_workspace_state(state, branch, *workspace_id, head, switch),
_ => Err(GitCompatError::InvalidState),
}
}
fn record_generated_commit<E: std::error::Error + 'static>(
state: &mut GitCompatState,
tree: GitTreeRef,
parents: Vec<GitCommitId>,
tracked_paths: BTreeSet<String>,
message: String,
previous: Option<GitCommitId>,
) -> Result<GitCommandOutput, GitCompatError<E>> {
let commit = GitCommit::new_with_metadata(
tree,
tree,
tracked_paths.clone(),
parents,
"git-compat",
0,
message,
);
state.reflog.insert(0, previous);
state.commits.insert(commit.id, commit.clone());
let branch = state
.current_mut()
.map_err(|_| GitCompatError::InvalidState)?;
branch.head = Some(commit.id);
branch.tracked_paths = tracked_paths;
Ok(GitCommandOutput::Committed(commit))
}
fn map_state_error<E: std::error::Error + 'static>(
error: GitCompatStateError,
) -> GitCompatError<E> {
match error {
GitCompatStateError::Invalid => GitCompatError::InvalidState,
GitCompatStateError::Unknown(name) => GitCompatError::UnknownObject(name),
GitCompatStateError::BranchExists(name) => GitCompatError::BranchExists(name),
GitCompatStateError::UnbornHead => GitCompatError::UnbornHead,
GitCompatStateError::NothingToCommit => GitCompatError::NothingToCommit,
GitCompatStateError::EmptyStash => GitCompatError::UnknownObject("stash@{0}".to_owned()),
GitCompatStateError::Bisect(message) => GitCompatError::InvalidCommand(message),
}
}
fn resolve_object(
state: &GitCompatState,
object: Option<&GitObjectName>,
) -> Result<GitCommitId, GitCompatStateError> {
let Some(object) = object else {
return visible_head(state).ok_or(GitCompatStateError::UnbornHead);
};
if object.0 == "HEAD" {
return visible_head(state).ok_or(GitCompatStateError::UnbornHead);
}
if let Some(branch) = state.branches.get(&object.0) {
return branch.head.ok_or(GitCompatStateError::UnbornHead);
}
if let Some(id) = state.tags.get(&object.0) {
return Ok(*id);
}
let matching: Vec<_> = state
.commits
.keys()
.filter(|id| id.to_hex().starts_with(&object.0))
.copied()
.collect();
match matching.as_slice() {
[id] => Ok(*id),
_ => Err(GitCompatStateError::Unknown(object.0.clone())),
}
}
fn visible_head(state: &GitCompatState) -> Option<GitCommitId> {
state
.bisect
.as_ref()
.and_then(|bisect| bisect.current)
.or_else(|| state.current().ok().and_then(|branch| branch.head))
}
fn execute_bisect(
state: &mut GitCompatState,
branch: &GitBranch,
workspace: GitTreeRef,
arguments: &[String],
) -> Result<GitCommandOutput, GitCompatStateError> {
let subcommand = arguments.first().map_or("start", String::as_str);
match subcommand {
"start" => {
if state.bisect.is_some() {
return Err(GitCompatStateError::Bisect(
"a bisect session is already active".to_owned(),
));
}
let bad = arguments
.get(1)
.map(|value| resolve_object(state, Some(&GitObjectName(value.clone()))))
.transpose()?
.or(branch.head)
.ok_or(GitCompatStateError::UnbornHead)?;
let good = arguments
.get(2)
.map(|value| resolve_object(state, Some(&GitObjectName(value.clone()))))
.transpose()?;
let bad_record = state
.commits
.get(&bad)
.ok_or(GitCompatStateError::Invalid)?;
if bad_record.workspace_tree != workspace {
return Err(GitCompatStateError::Bisect(
"the working copy must match the bad commit before bisect starts".to_owned(),
));
}
let session = GitBisectState {
original_branch: state.current_branch.clone(),
original_head: branch.head.ok_or(GitCompatStateError::UnbornHead)?,
good,
bad,
current: Some(bad),
skipped: BTreeSet::new(),
};
advance_bisect(state, branch, session)
}
"good" | "bad" | "skip" => {
let mut session = state.bisect.clone().ok_or_else(|| {
GitCompatStateError::Bisect("start a bisect session first".to_owned())
})?;
let marked = arguments
.get(1)
.map(|value| resolve_object(state, Some(&GitObjectName(value.clone()))))
.transpose()?
.or(session.current)
.ok_or_else(|| {
GitCompatStateError::Bisect("no current commit is selected".to_owned())
})?;
match subcommand {
"good" => session.good = Some(marked),
"bad" => session.bad = marked,
"skip" => {
session.skipped.insert(marked);
}
_ => unreachable!(),
}
advance_bisect(state, branch, session)
}
"reset" => {
let session = state.bisect.clone().ok_or_else(|| {
GitCompatStateError::Bisect("no bisect session is active".to_owned())
})?;
let result = GitBisectResult {
active: false,
good: session.good,
bad: Some(session.bad),
current: None,
remaining: 0,
first_bad: None,
};
prepare_bisect_checkout(state, branch, session.original_head, None, result)
}
"log" | "visualize" => Ok(GitCommandOutput::Bisect(bisect_result(
state,
state.bisect.as_ref(),
)?)),
other => Err(GitCompatStateError::Bisect(format!(
"unsupported bisect operation '{other}'"
))),
}
}
fn advance_bisect(
state: &mut GitCompatState,
branch: &GitBranch,
mut session: GitBisectState,
) -> Result<GitCommandOutput, GitCompatStateError> {
let Some(good) = session.good else {
session.current = Some(session.bad);
let result = bisect_result(state, Some(&session))?;
state.bisect = Some(session);
return Ok(GitCommandOutput::Bisect(result));
};
let chain = first_parent_range(state, session.bad, good)?;
let candidates: Vec<_> = chain
.iter()
.copied()
.skip(1)
.take(chain.len().saturating_sub(2))
.filter(|commit| !session.skipped.contains(commit))
.collect();
if candidates.is_empty() {
session.current = None;
let result = GitBisectResult {
active: true,
good: Some(good),
bad: Some(session.bad),
current: None,
remaining: 0,
first_bad: Some(session.bad),
};
state.bisect = Some(session);
return Ok(GitCommandOutput::Bisect(result));
}
let candidate = *candidates
.get(candidates.len() / 2)
.ok_or_else(|| GitCompatStateError::Bisect("no bisection candidate".to_owned()))?;
session.current = Some(candidate);
let result = GitBisectResult {
active: true,
good: Some(good),
bad: Some(session.bad),
current: Some(candidate),
remaining: u32::try_from(candidates.len()).unwrap_or(u32::MAX),
first_bad: None,
};
prepare_bisect_checkout(state, branch, candidate, Some(session), result)
}
fn prepare_bisect_checkout(
state: &mut GitCompatState,
branch: &GitBranch,
commit: GitCommitId,
replacement: Option<GitBisectState>,
result: GitBisectResult,
) -> Result<GitCommandOutput, GitCompatStateError> {
let record = state
.commits
.get(&commit)
.ok_or(GitCompatStateError::Invalid)?;
let paths = branch
.tracked_paths
.union(&record.tracked_paths)
.cloned()
.collect();
Ok(prepare_transition(
state,
GitFilesystemAction::RestoreGeneration {
tree: record.tree,
paths: Some(paths),
},
GitPendingMutation::Bisect {
state: Box::new(replacement),
result,
},
))
}
fn first_parent_range(
state: &GitCompatState,
bad: GitCommitId,
good: GitCommitId,
) -> Result<Vec<GitCommitId>, GitCompatStateError> {
let mut range = Vec::new();
let mut next = Some(bad);
let mut seen = BTreeSet::new();
while let Some(commit) = next {
if !seen.insert(commit) {
return Err(GitCompatStateError::Invalid);
}
range.push(commit);
if commit == good {
return Ok(range);
}
next = state
.commits
.get(&commit)
.ok_or(GitCompatStateError::Invalid)?
.parents
.first()
.copied();
}
Err(GitCompatStateError::Bisect(
"the good commit is not a first-parent ancestor of the bad commit".to_owned(),
))
}
fn bisect_result(
state: &GitCompatState,
session: Option<&GitBisectState>,
) -> Result<GitBisectResult, GitCompatStateError> {
let Some(session) = session else {
return Ok(GitBisectResult {
active: false,
good: None,
bad: None,
current: None,
remaining: 0,
first_bad: None,
});
};
let remaining = match session.good {
Some(good) => first_parent_range(state, session.bad, good)?
.into_iter()
.skip(1)
.filter(|commit| *commit != good && !session.skipped.contains(commit))
.count(),
None => 0,
};
Ok(GitBisectResult {
active: true,
good: session.good,
bad: Some(session.bad),
current: session.current,
remaining: u32::try_from(remaining).unwrap_or(u32::MAX),
first_bad: session.good.filter(|_| remaining == 0).map(|_| session.bad),
})
}
fn commit_generation_ref(commit: &GitCommit, _fallback: WorkspaceId) -> GitTreeRef {
commit.tree
}
fn walk_commits(
state: &GitCompatState,
mut next: Option<GitCommitId>,
maximum: u32,
) -> Vec<GitCommit> {
let mut commits = Vec::new();
while let Some(id) = next {
if commits.len() >= usize::try_from(maximum).unwrap_or(usize::MAX) {
break;
}
let Some(commit) = state.commits.get(&id) else {
break;
};
commits.push(commit.clone());
next = commit.parents.first().copied();
}
commits
}
fn expand_pathspecs(paths: &[String], tracked_paths: &BTreeSet<String>) -> Vec<String> {
let mut expanded = BTreeSet::new();
for path in paths {
let path = path.trim_matches('/');
let mut matched = false;
for tracked in tracked_paths {
if tracked == path || is_path_below(tracked, path) {
expanded.insert(tracked.clone());
matched = true;
}
}
if !matched && !path.is_empty() {
expanded.insert(path.to_owned());
}
}
expanded.into_iter().collect()
}
#[allow(clippy::too_many_lines)]
#[allow(
clippy::cognitive_complexity,
reason = "the argv compatibility matrix is intentionally exhaustive and centralized"
)]
fn parse_argv<E: std::error::Error + 'static>(
argv: &[String],
default_author: &str,
now_seconds: i64,
) -> Result<GitCommand, GitCompatError<E>> {
reject_shell_composition(argv)?;
let Some(command) = argv.first().map(String::as_str) else {
return Err(GitCompatError::InvalidCommand("missing command".to_owned()));
};
let args = argv.get(1..).unwrap_or_default();
match command {
"status" => {
require_only_options(command, args, &["--short", "--porcelain", "--porcelain=v1"])?;
Ok(GitCommand::Status)
}
"diff" => {
require_only_options(command, args, &["--cached", "--staged"])?;
Ok(GitCommand::Diff {
cached: args
.iter()
.any(|arg| arg == "--cached" || arg == "--staged"),
})
}
"log" => Ok(GitCommand::Log {
maximum: parse_log_maximum(args)?,
}),
"show" => Ok(GitCommand::Show {
object: optional_single_value(command, args)?.map(GitObjectName),
}),
"add" => {
if args.is_empty() {
return Err(GitCompatError::InvalidCommand(
"add requires a path or -A/--all".to_owned(),
));
}
let mut options_ended = false;
let mut stages_without_paths = false;
let mut paths = Vec::new();
for arg in args {
if !options_ended && arg == "--" {
options_ended = true;
} else if !options_ended && arg.starts_with('-') {
if matches!(arg.as_str(), "-A" | "--all" | "-u" | "--update") {
stages_without_paths = true;
} else {
return unsupported_option(command, arg);
}
} else {
paths.push(arg.clone());
}
}
if paths.is_empty() && !stages_without_paths {
return Err(GitCompatError::InvalidCommand(
"add requires a path or -A/--all".to_owned(),
));
}
Ok(GitCommand::Add { paths })
}
"commit" => {
let mut message = None;
let mut author = default_author.to_owned();
let mut index = 0;
while index < args.len() {
let Some(argument) = args.get(index) else {
break;
};
let (name, inline) = split_long_option(argument);
if name == "-m" || name == "--message" {
let value = inline
.map(str::to_owned)
.or_else(|| args.get(index + 1).cloned())
.ok_or_else(|| {
GitCompatError::InvalidCommand("commit requires a message after -m".to_owned())
})?;
if message.replace(value).is_some() {
return Err(GitCompatError::InvalidCommand(
"multiple commit messages are not supported".to_owned(),
));
}
index += usize::from(inline.is_none());
} else if name == "--author" {
author = inline
.map(str::to_owned)
.or_else(|| args.get(index + 1).cloned())
.ok_or_else(|| {
GitCompatError::InvalidCommand(
"commit requires an author after --author".to_owned(),
)
})?;
index += usize::from(inline.is_none());
} else {
return unsupported_option(command, argument);
}
index += 1;
}
let message = message.ok_or_else(|| {
GitCompatError::InvalidCommand("commit requires -m".to_owned())
})?;
Ok(GitCommand::Commit {
message,
author,
authored_at_seconds: now_seconds,
})
}
"branch" => Ok(GitCommand::Branch {
create: optional_single_value(command, args)?,
}),
"switch" => {
let (create, values) = positional_with_options(command, args, &["-c"])?;
let branch = exactly_one(command, &values)?;
Ok(GitCommand::Switch { branch, create })
}
"checkout" => {
if let Some(separator) = args.iter().position(|argument| argument == "--") {
if separator > 1 {
return Err(GitCompatError::InvalidCommand(
"checkout path restoration accepts at most one source object".to_owned(),
));
}
let paths = args.get(separator + 1..).unwrap_or_default().to_vec();
if paths.is_empty() {
return Err(GitCompatError::InvalidCommand(
"checkout -- requires at least one path".to_owned(),
));
}
return Ok(GitCommand::Restore {
source: args.first().map(|source| GitObjectName(source.clone())),
paths,
});
}
let (create, values) = positional_with_options(command, args, &["-b"])?;
let branch = exactly_one(command, &values)?;
Ok(GitCommand::Switch { branch, create })
}
"restore" => {
let mut source = None;
let mut paths = Vec::new();
let mut options_ended = false;
let mut index = 0;
while index < args.len() {
let Some(argument) = args.get(index) else {
break;
};
if !options_ended && argument == "--" {
options_ended = true;
} else if !options_ended {
let (name, inline) = split_long_option(argument);
if name == "--source" {
let value = inline
.map(str::to_owned)
.or_else(|| args.get(index + 1).cloned())
.ok_or_else(|| {
GitCompatError::InvalidCommand(
"restore requires an object after --source".to_owned(),
)
})?;
if source.replace(GitObjectName(value)).is_some() {
return Err(GitCompatError::InvalidCommand(
"restore accepts only one --source".to_owned(),
));
}
index += usize::from(inline.is_none());
} else if argument.starts_with('-') {
return unsupported_option(command, argument);
} else {
paths.push(argument.clone());
}
} else {
paths.push(argument.clone());
}
index += 1;
}
if paths.is_empty() {
return Err(GitCompatError::InvalidCommand(
"restore requires at least one path".to_owned(),
));
}
Ok(GitCommand::Restore { source, paths })
}
"reset" => {
require_options(command, args, &["--hard", "--soft", "--mixed"])?;
let modes = args
.iter()
.filter(|arg| matches!(arg.as_str(), "--hard" | "--soft" | "--mixed"))
.count();
if modes > 1 {
return Err(GitCompatError::InvalidCommand(
"reset accepts only one mode".to_owned(),
));
}
let mode = if args.iter().any(|arg| arg == "--hard") {
GitResetMode::Hard
} else if args.iter().any(|arg| arg == "--soft") {
GitResetMode::Soft
} else {
GitResetMode::Mixed
};
let targets = args
.iter()
.filter(|arg| !arg.starts_with('-'))
.cloned()
.collect::<Vec<_>>();
let target = match targets.as_slice() {
[] => "HEAD".to_owned(),
[target] => target.clone(),
_ => return Err(GitCompatError::InvalidCommand(
"reset accepts only one target; path reset is not supported".to_owned(),
)),
};
Ok(GitCommand::Reset {
target: GitObjectName(target),
mode,
})
}
"merge" if matches!(args, [operation] if operation == "--continue") => {
Ok(GitCommand::MergeContinue)
}
"merge" if matches!(args, [operation] if operation == "--abort") => {
Ok(GitCommand::MergeAbort)
}
"merge" | "rebase" => {
let branch = exactly_one(command, args)?;
if command == "merge" {
Ok(GitCommand::Merge { branch })
} else {
Ok(GitCommand::Rebase { branch })
}
}
"cherry-pick" | "revert" => {
let object = GitObjectName(exactly_one(command, args)?);
if command == "cherry-pick" {
Ok(GitCommand::CherryPick { object })
} else {
Ok(GitCommand::Revert { object })
}
}
"stash" => match args {
[] => Ok(GitCommand::StashPush),
[operation] if operation == "push" => Ok(GitCommand::StashPush),
[operation] if operation == "pop" => Ok(GitCommand::StashPop),
[other] => Err(GitCompatError::InvalidCommand(format!(
"unsupported stash operation '{other}'"
))),
_ => Err(GitCompatError::InvalidCommand(
"stash accepts only push or pop".to_owned(),
)),
},
"tag" => {
let delete = args.iter().any(|arg| arg == "-d" || arg == "--delete");
require_options(command, args, &["-d", "--delete"])?;
let values: Vec<_> = args.iter().filter(|arg| !arg.starts_with('-')).cloned().collect();
if values.len() > 2 || (delete && values.len() != 1) {
return Err(GitCompatError::InvalidCommand(
"tag accepts [name [target]] or --delete name".to_owned(),
));
}
Ok(GitCommand::Tag {
name: values.first().cloned(),
target: values.get(1).cloned().map(GitObjectName),
delete,
})
}
"blame" => Ok(GitCommand::Blame {
path: exactly_one(command, args)?,
}),
"grep" => match args {
[pattern] => Ok(GitCommand::Grep {
pattern: pattern.clone(),
path: None,
}),
[pattern, path] => Ok(GitCommand::Grep {
pattern: pattern.clone(),
path: Some(path.clone()),
}),
_ => Err(GitCompatError::InvalidCommand(
"grep requires a pattern and at most one path".to_owned(),
)),
},
"clean" => {
require_only_options(
command,
args,
&["-n", "--dry-run", "-f", "--force"],
)?;
let dry_run = args
.iter()
.any(|arg| matches!(arg.as_str(), "-n" | "--dry-run"));
let force = args
.iter()
.any(|arg| matches!(arg.as_str(), "-f" | "--force"));
if !dry_run && !force {
return Err(GitCompatError::InvalidCommand(
"clean requires -f or --force unless --dry-run is used".to_owned(),
));
}
Ok(GitCommand::Clean { dry_run })
}
"archive" => Ok(GitCommand::Archive {
object: optional_single_value(command, args)?.map(GitObjectName),
}),
"apply" => Err(GitCompatError::Unsupported {
command: command.to_owned(),
reason: "argv cannot safely turn a patch filename into patch bytes; load the file and use the typed Apply command".to_owned(),
}),
"bisect" => Ok(GitCommand::Bisect {
arguments: args.to_vec(),
}),
"rev-parse" => match args {
[argument] if matches!(argument.as_str(), "--is-inside-work-tree" | "HEAD") => {
Ok(GitCommand::RevParse {
argument: argument.clone(),
})
}
[option, head]
if head == "HEAD"
&& matches!(
option.as_str(),
"--abbrev-ref" | "--symbolic-full-name"
) => Ok(GitCommand::RevParse {
argument: format!("{option} HEAD"),
}),
[object] if !object.starts_with('-') => Ok(GitCommand::RevParse {
argument: object.clone(),
}),
_ => Err(GitCompatError::InvalidCommand(
"rev-parse supports HEAD, one object, --is-inside-work-tree, --abbrev-ref HEAD, or --symbolic-full-name HEAD".to_owned(),
)),
},
"symbolic-ref" => {
let short = args.iter().any(|argument| argument == "--short");
require_options(command, args, &["--short"])?;
let values = args
.iter()
.filter(|argument| !argument.starts_with('-'))
.collect::<Vec<_>>();
if values.as_slice() != [&"HEAD"] {
return Err(GitCompatError::InvalidCommand(
"symbolic-ref supports only HEAD".to_owned(),
));
}
Ok(GitCommand::SymbolicRef { short })
}
"merge-base" => match args {
[left, right] => Ok(GitCommand::MergeBase {
left: GitObjectName(left.clone()),
right: GitObjectName(right.clone()),
}),
_ => Err(GitCompatError::InvalidCommand(
"merge-base requires exactly two objects".to_owned(),
)),
},
"ls-files" => {
require_only_options(command, args, &["--cached"])?;
Ok(GitCommand::LsFiles)
}
"check-ignore" => {
let mut paths = Vec::new();
for argument in args {
if argument == "--" {
continue;
}
if argument.starts_with('-') {
return unsupported_option(command, argument);
}
paths.push(argument.clone());
}
if paths.is_empty() {
return Err(GitCompatError::InvalidCommand(
"check-ignore requires at least one path".to_owned(),
));
}
Ok(GitCommand::CheckIgnore { paths })
}
"clone" | "fetch" | "pull" | "push" | "remote" | "gc" | "repack" | "cat-file"
| "hash-object" | "init" | "fsck" | "prune" | "pack-objects" | "index-pack"
| "receive-pack" | "upload-pack" | "read-tree" | "write-tree" | "commit-tree"
| "update-index" | "worktree" | "submodule" => Err(GitCompatError::Unsupported {
command: command.to_owned(),
reason: "the compatibility layer has no Git object database or transport".to_owned(),
}),
other => Err(GitCompatError::InvalidCommand(format!(
"unknown command '{other}'"
))),
}
}
fn reject_shell_composition<E: std::error::Error + 'static>(
argv: &[String],
) -> Result<(), GitCompatError<E>> {
if let Some(token) = argv.iter().find(|token| {
matches!(token.as_str(), "&&" | "||" | ";" | "|" | "&")
|| token.starts_with('>')
|| token.starts_with('<')
|| token.contains('`')
|| token.contains("$(")
}) {
return Err(GitCompatError::Unsupported {
command: token.clone(),
reason: "shell composition is outside the command-level compatibility façade"
.to_owned(),
});
}
Ok(())
}
fn require_options<E: std::error::Error + 'static>(
command: &str,
args: &[String],
allowed: &[&str],
) -> Result<(), GitCompatError<E>> {
if let Some(argument) = args
.iter()
.find(|argument| argument.starts_with('-') && !allowed.contains(&argument.as_str()))
{
return unsupported_option(command, argument);
}
Ok(())
}
fn require_only_options<E: std::error::Error + 'static>(
command: &str,
args: &[String],
allowed: &[&str],
) -> Result<(), GitCompatError<E>> {
require_options(command, args, allowed)?;
if args.iter().any(|argument| !argument.starts_with('-')) {
return Err(GitCompatError::InvalidCommand(format!(
"{command} does not accept positional arguments"
)));
}
Ok(())
}
fn unsupported_option<T, E: std::error::Error + 'static>(
command: &str,
argument: &str,
) -> Result<T, GitCompatError<E>> {
Err(GitCompatError::Unsupported {
command: command.to_owned(),
reason: format!("option '{argument}' cannot be represented by the compatibility façade"),
})
}
fn optional_single_value<E: std::error::Error + 'static>(
command: &str,
args: &[String],
) -> Result<Option<String>, GitCompatError<E>> {
match args {
[] => Ok(None),
[value] if !value.starts_with('-') => Ok(Some(value.clone())),
[option] => unsupported_option(command, option),
_ => Err(GitCompatError::InvalidCommand(format!(
"{command} accepts at most one argument"
))),
}
}
fn exactly_one<E: std::error::Error + 'static>(
command: &str,
values: &[String],
) -> Result<String, GitCompatError<E>> {
match values {
[value] if !value.starts_with('-') => Ok(value.clone()),
[option] => unsupported_option(command, option),
_ => Err(GitCompatError::InvalidCommand(format!(
"{command} requires exactly one argument"
))),
}
}
fn positional_with_options<E: std::error::Error + 'static>(
command: &str,
args: &[String],
allowed: &[&str],
) -> Result<(bool, Vec<String>), GitCompatError<E>> {
require_options(command, args, allowed)?;
Ok((
args.iter()
.any(|argument| allowed.contains(&argument.as_str())),
args.iter()
.filter(|argument| !allowed.contains(&argument.as_str()))
.cloned()
.collect(),
))
}
fn split_long_option(argument: &str) -> (&str, Option<&str>) {
argument
.split_once('=')
.map_or((argument, None), |(name, value)| (name, Some(value)))
}
fn parse_log_maximum<E: std::error::Error + 'static>(
args: &[String],
) -> Result<u32, GitCompatError<E>> {
if args.is_empty() {
return Ok(100);
}
let value = match args {
[option, value] if option == "-n" || option == "--max-count" => value.as_str(),
[option] if option.starts_with("--max-count=") => option
.split_once('=')
.map(|(_, value)| value)
.unwrap_or_default(),
[option] if option.len() > 1 => option
.strip_prefix('-')
.ok_or_else(|| GitCompatError::InvalidCommand("invalid log count".to_owned()))?,
[option] => return unsupported_option("log", option),
_ => {
return Err(GitCompatError::InvalidCommand(
"log accepts only -n <count>, -<count>, or --max-count=<count>".to_owned(),
));
}
};
value.parse::<u32>().map_err(|_| {
GitCompatError::InvalidCommand("log count must be an unsigned 32-bit integer".to_owned())
})
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitIgnorePolicy {
rules: Vec<GitIgnoreRule>,
}
pub struct GitCapturedGeneration<A, O> {
pub generation: Generation<A, O>,
initial_generation: Option<crate::GenerationId>,
pub tracked_paths: BTreeSet<String>,
fork_parent: Option<Generation<A, O>>,
}
impl<A: AsyncAuthorityStore, O: AsyncObjectStore> GitCapturedGeneration<A, O> {
pub async fn authenticated_proof<S: crate::WorkspaceLineageStore>(
&self,
operation_id: OperationId,
graph: &crate::WorkspaceGraph<S>,
) -> Result<Option<GitCaptureProof>, GitCaptureAuthenticationError<S::Error>> {
let proof = self.verified_proof(operation_id).await?;
if let (Some(parent), Some(initial_generation)) =
(&self.fork_parent, self.initial_generation)
{
graph
.register_existing_child_at_generation(
&parent.workspace(),
&self.generation.workspace(),
parent.id(),
initial_generation,
)
.await?;
}
Ok(proof)
}
async fn verified_proof(
&self,
operation_id: OperationId,
) -> Result<Option<GitCaptureProof>, GitCaptureError> {
let (parent, initial_generation) = match (&self.fork_parent, self.initial_generation) {
(Some(parent), Some(initial_generation)) => (parent, initial_generation),
(None, None) => return Ok(None),
_ => return Err(GitCaptureError::Conflict),
};
let child = self.generation.workspace();
let expected_suffix = format!("-{}", hex::encode(operation_id.into_bytes()));
if !child.name().as_str().starts_with("git-capture-")
|| !child.name().as_str().ends_with(&expected_suffix)
{
return Err(GitCaptureError::Conflict);
}
let expected_child = parent
.workspace()
.fork_workspace_id(child.name().as_str())
.map_err(WorkspaceError::from)?;
if child.id() != expected_child {
return Err(GitCaptureError::Conflict);
}
let initial = child.generation(initial_generation).await?;
if initial.parents().await?.as_slice() != [parent.id()] {
return Err(GitCaptureError::Conflict);
}
let result_parents = self.generation.parents().await?;
if self.generation.id() != initial_generation
&& result_parents.as_slice() != [initial_generation]
{
return Err(GitCaptureError::Conflict);
}
Ok(Some(GitCaptureProof {
fork_parent: GitTreeRef::exact(parent.workspace_id(), parent.id()),
initial_generation,
operation_id,
}))
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitTreeEntry {
pub path: String,
pub kind: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitGrepMatch {
pub path: String,
pub line: u64,
pub text: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitGrepResult {
pub matches: Vec<GitGrepMatch>,
pub truncated: bool,
}
#[derive(Debug, Error)]
pub enum GitPatchError {
#[error(transparent)]
Workspace(#[from] WorkspaceError),
#[error("Git patch is not UTF-8 unified text")]
NonText,
#[error("invalid Git unified patch: {0}")]
Invalid(String),
#[error("Git patch hunk does not apply to '{path}' at source line {line}")]
Conflict {
path: String,
line: usize,
},
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitBlameLine {
pub commit: GitCommitId,
pub line: u64,
pub text: String,
}
pub async fn walk_git_tree<A: AsyncAuthorityStore, O: AsyncObjectStore>(
generation: &Generation<A, O>,
root: Option<&str>,
maximum_entries: u32,
) -> Result<Vec<GitTreeEntry>, WorkspaceError> {
if maximum_entries == 0 {
return Ok(Vec::new());
}
let root = root.unwrap_or("").trim_matches('/');
let start = if root.is_empty() {
"/".to_owned()
} else {
format!("/{root}")
};
if !root.is_empty() {
let stat = generation.stat(&start).await?;
if stat.kind != FileKind::Directory {
return Ok(vec![GitTreeEntry {
path: root.to_owned(),
kind: git_kind(stat.kind).to_owned(),
}]);
}
}
let maximum = usize::try_from(maximum_entries).unwrap_or(usize::MAX);
let mut entries = Vec::new();
let mut pending = vec![start];
while let Some(directory) = pending.pop() {
let mut after = None;
loop {
let page = generation
.list_directory(&directory, after.as_ref(), 1_024)
.await?;
for entry in &page.entries {
let name = entry
.name
.unicode_text()
.ok_or_else(|| WorkspaceError::path("non-portable Git path"))?;
let parent = directory.trim_start_matches('/');
let path = if parent.is_empty() {
name.to_string()
} else {
format!("{parent}/{name}")
};
entries.push(GitTreeEntry {
path: path.clone(),
kind: git_kind(entry.kind).to_owned(),
});
if entries.len() >= maximum {
entries.sort_by(|left, right| left.path.cmp(&right.path));
return Ok(entries);
}
if entry.kind == FileKind::Directory {
pending.push(format!("/{path}"));
}
}
if !page.has_more {
break;
}
after = page.entries.last().map(|entry| entry.name.clone());
}
}
entries.sort_by(|left, right| left.path.cmp(&right.path));
Ok(entries)
}
pub async fn grep_git_generation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
generation: &Generation<A, O>,
pattern: &str,
root: Option<&str>,
maximum_entries: u32,
maximum_file_bytes: u64,
maximum_matches: u32,
) -> Result<GitGrepResult, WorkspaceError> {
if maximum_entries == 0 {
return Ok(GitGrepResult {
matches: Vec::new(),
truncated: true,
});
}
let cancellation = CancellationToken::new();
let checkout = generation
.workspace
.engine_checkout(
GenerationSelector::Exact(generation.id()),
CheckoutMode::read_only_pinned(),
)
.await?;
let reader = checkout.pinned_reader().map_err(WorkspaceError::engine)?;
let limits = checkout.volume_config().limits;
let root = root.unwrap_or("/");
let root_path = customer_path(
&format!("/{}", root.trim_start_matches('/')),
checkout.volume_config(),
)?;
let root_display = root.trim_matches('/').to_owned();
let maximum_entries = usize::try_from(maximum_entries).unwrap_or(usize::MAX);
let (regular, mut truncated) = resolved_git_grep_files(
&reader,
root_display,
root_path,
limits,
maximum_entries,
&cancellation,
)
.await?;
let mut matches = Vec::new();
let maximum_matches = usize::try_from(maximum_matches).unwrap_or(usize::MAX);
for batch in regular.chunks(GREP_READ_CONCURRENCY) {
let mut requests = Vec::with_capacity(batch.len());
let mut selected = Vec::with_capacity(batch.len());
for (path, file) in batch {
if file.description().logical_bytes > maximum_file_bytes {
truncated = true;
continue;
}
requests.push(ResolvedFileRangeReadRequest {
file,
range: ByteRange {
offset: 0,
length: file.description().logical_bytes,
},
});
selected.push(path);
}
if requests.is_empty() {
continue;
}
let reads = reader
.read_resolved_ranges(
&requests,
GREP_READ_CONCURRENCY,
WorkBudget::UNBOUNDED,
&cancellation,
)
.await
.map_err(WorkspaceError::engine)?
.value;
for (path, read) in selected.into_iter().zip(reads) {
let bytes = read.bytes;
let Ok(text) = std::str::from_utf8(&bytes) else {
continue;
};
for (index, line) in text.lines().enumerate() {
if line.contains(pattern) {
if matches.len() >= maximum_matches {
return Ok(GitGrepResult {
matches,
truncated: true,
});
}
matches.push(GitGrepMatch {
path: path.clone(),
line: u64::try_from(index).unwrap_or(u64::MAX).saturating_add(1),
text: line.to_owned(),
});
}
}
}
}
Ok(GitGrepResult { matches, truncated })
}
pub async fn blame_git_generations<A: AsyncAuthorityStore, O: AsyncObjectStore>(
history_newest_first: &[(GitCommit, Generation<A, O>)],
path: &str,
maximum_file_bytes: u64,
) -> Result<Vec<GitBlameLine>, WorkspaceError> {
let relative = path.trim_start_matches('/');
let absolute = format!("/{relative}");
let mut current = Vec::<String>::new();
let mut owners = Vec::<GitCommitId>::new();
for (commit, generation) in history_newest_first.iter().rev() {
let next = if commit.tracked_paths.contains(relative) {
let bytes = generation.read(&absolute, maximum_file_bytes).await?;
std::str::from_utf8(&bytes)
.map_err(|_| WorkspaceError::path("Git blame requires UTF-8 text"))?
.lines()
.map(str::to_owned)
.collect::<Vec<_>>()
} else {
Vec::new()
};
let mut next_owners = Vec::with_capacity(next.len());
for (index, line) in next.iter().enumerate() {
next_owners.push(if current.get(index) == Some(line) {
owners.get(index).copied().unwrap_or(commit.id)
} else {
commit.id
});
}
current = next;
owners = next_owners;
}
Ok(current
.into_iter()
.zip(owners)
.enumerate()
.map(|(index, (text, commit))| GitBlameLine {
commit,
line: u64::try_from(index).unwrap_or(u64::MAX).saturating_add(1),
text,
})
.collect())
}
#[derive(Clone, Debug)]
struct UnifiedPatch {
old_path: Option<String>,
new_path: Option<String>,
hunks: Vec<UnifiedHunk>,
}
#[derive(Clone, Debug)]
struct UnifiedHunk {
old_start: usize,
lines: Vec<String>,
}
pub async fn apply_git_patch<A: AsyncAuthorityStore, O: AsyncObjectStore>(
workspace: &Workspace<A, O>,
patch: &[u8],
idempotency_key: IdempotencyKey,
) -> Result<TransactionCommit<A, O>, GitPatchError> {
apply_git_patch_with_permit(
workspace,
patch,
idempotency_key,
crate::PublicationPermit::Unrestricted,
)
.await
}
pub async fn apply_git_patch_with_permit<A: AsyncAuthorityStore, O: AsyncObjectStore>(
workspace: &Workspace<A, O>,
patch: &[u8],
idempotency_key: IdempotencyKey,
permit: crate::PublicationPermit,
) -> Result<TransactionCommit<A, O>, GitPatchError> {
if let Some(generation) = workspace.operation_generation(idempotency_key).await? {
return Ok(TransactionCommit::AlreadyCommitted(generation));
}
let patches = parse_unified_patch(patch)?;
let head = workspace.head().await?;
let mut transaction = workspace.begin_transaction(idempotency_key).await?;
for patch in patches {
let path = patch
.new_path
.as_ref()
.or(patch.old_path.as_ref())
.ok_or_else(|| GitPatchError::Invalid("patch has no path".to_owned()))?;
let original = if patch.old_path.is_none() {
String::new()
} else {
let bytes = head
.read(&format!("/{path}"), MAXIMUM_PATCH_FILE_BYTES)
.await?;
String::from_utf8(bytes.to_vec()).map_err(|_| GitPatchError::NonText)?
};
let replacement = apply_unified_hunks(path, &original, &patch.hunks)?;
if patch.new_path.is_none() {
if !replacement.is_empty() {
return Err(GitPatchError::Invalid(
"deleted-file patch leaves content".to_owned(),
));
}
transaction.remove(&format!("/{path}")).await?;
continue;
}
if let Some((parent, _)) = path.rsplit_once('/') {
transaction.create_dir_all(&format!("/{parent}")).await?;
}
transaction
.write_text(&format!("/{path}"), &replacement)
.await?;
}
transaction
.commit_with_permit(permit)
.await
.map_err(Into::into)
}
#[allow(
clippy::indexing_slicing,
reason = "every patch line index is bounded by the surrounding length checks"
)]
fn parse_unified_patch(bytes: &[u8]) -> Result<Vec<UnifiedPatch>, GitPatchError> {
let text = std::str::from_utf8(bytes).map_err(|_| GitPatchError::NonText)?;
if text.contains("GIT binary patch") {
return Err(GitPatchError::Invalid(
"binary patches require a typed binary mutation".to_owned(),
));
}
let lines: Vec<_> = text.split_inclusive('\n').collect();
let mut patches = Vec::new();
let mut index = 0;
while index < lines.len() {
if !lines[index].starts_with("--- ") {
index += 1;
continue;
}
let old_path = parse_patch_path(lines[index].trim_end_matches(['\r', '\n']), "--- ")?;
index += 1;
let Some(next) = lines.get(index) else {
return Err(GitPatchError::Invalid("missing +++ header".to_owned()));
};
let new_path = parse_patch_path(next.trim_end_matches(['\r', '\n']), "+++ ")?;
index += 1;
let mut hunks = Vec::new();
while index < lines.len() && !lines[index].starts_with("--- ") {
if !lines[index].starts_with("@@ ") {
index += 1;
continue;
}
let old_start = parse_hunk_old_start(lines[index])?;
index += 1;
let mut hunk_lines: Vec<String> = Vec::new();
while index < lines.len()
&& !lines[index].starts_with("@@ ")
&& !lines[index].starts_with("--- ")
{
let line = lines[index];
if line.starts_with('\\') {
let previous = hunk_lines.last_mut().ok_or_else(|| {
GitPatchError::Invalid(
"no-newline marker does not follow a hunk line".to_owned(),
)
})?;
if previous.ends_with('\n') {
previous.pop();
if previous.ends_with('\r') {
previous.pop();
}
}
} else if line.starts_with([' ', '+', '-']) {
hunk_lines.push(line.to_owned());
} else {
return Err(GitPatchError::Invalid(
"hunk line lacks a unified-diff prefix".to_owned(),
));
}
index += 1;
}
hunks.push(UnifiedHunk {
old_start,
lines: hunk_lines,
});
}
if hunks.is_empty() {
return Err(GitPatchError::Invalid("patch contains no hunks".to_owned()));
}
patches.push(UnifiedPatch {
old_path,
new_path,
hunks,
});
}
if patches.is_empty() {
return Err(GitPatchError::Invalid("patch contains no files".to_owned()));
}
Ok(patches)
}
fn parse_patch_path(line: &str, prefix: &str) -> Result<Option<String>, GitPatchError> {
let value = line
.strip_prefix(prefix)
.ok_or_else(|| GitPatchError::Invalid(format!("missing {prefix}header")))?
.split('\t')
.next()
.unwrap_or_default();
if value == "/dev/null" {
return Ok(None);
}
let value = value
.strip_prefix("a/")
.or_else(|| value.strip_prefix("b/"))
.unwrap_or(value)
.trim_matches('/');
if value.is_empty()
|| value.contains('\\')
|| value.contains(':')
|| value
.split('/')
.any(|component| component.is_empty() || component == "." || component == "..")
{
return Err(GitPatchError::Invalid(
"patch path escapes its root".to_owned(),
));
}
Ok(Some(value.to_owned()))
}
fn parse_hunk_old_start(line: &str) -> Result<usize, GitPatchError> {
let range = line
.strip_prefix("@@ -")
.and_then(|line| line.split_whitespace().next())
.ok_or_else(|| GitPatchError::Invalid("malformed hunk header".to_owned()))?;
range
.split(',')
.next()
.and_then(|value| value.parse().ok())
.ok_or_else(|| GitPatchError::Invalid("malformed hunk source range".to_owned()))
}
fn apply_unified_hunks(
path: &str,
original: &str,
hunks: &[UnifiedHunk],
) -> Result<String, GitPatchError> {
let source: Vec<_> = original.split_inclusive('\n').collect();
let mut output = String::new();
let mut cursor = 0_usize;
for hunk in hunks {
let start = hunk.old_start.saturating_sub(1);
if start < cursor || start > source.len() {
return Err(GitPatchError::Conflict {
path: path.to_owned(),
line: hunk.old_start,
});
}
for line in source.get(cursor..start).unwrap_or_default() {
output.push_str(line);
}
cursor = start;
for line in &hunk.lines {
let Some(prefix) = line.chars().next() else {
continue;
};
let payload = line.get(prefix.len_utf8()..).unwrap_or_default();
match prefix {
' ' | '-' => {
if source.get(cursor).copied() != Some(payload) {
return Err(GitPatchError::Conflict {
path: path.to_owned(),
line: cursor.saturating_add(1),
});
}
if prefix == ' ' {
output.push_str(payload);
}
cursor = cursor.saturating_add(1);
}
'+' => output.push_str(payload),
_ => {
return Err(GitPatchError::Invalid(
"unsupported hunk line prefix".to_owned(),
));
}
}
}
}
for line in source.get(cursor..).unwrap_or_default() {
output.push_str(line);
}
Ok(output)
}
const fn git_kind(kind: FileKind) -> &'static str {
match kind {
FileKind::Regular => "regular",
FileKind::Directory => "directory",
FileKind::SymbolicLink => "symbolic-link",
FileKind::Fifo => "fifo",
FileKind::Socket => "socket",
FileKind::CharacterDevice => "character-device",
FileKind::BlockDevice => "block-device",
FileKind::ReparsePoint => "windows-reparse-point",
FileKind::MountBoundary => "mount-boundary",
}
}
#[derive(Debug, Error)]
pub enum GitCaptureError {
#[error(transparent)]
Workspace(#[from] WorkspaceError),
#[error("Git compatibility cannot represent a non-UTF-8 path name")]
NonPortableName,
#[error("Git compatibility capture workspace changed concurrently")]
Conflict,
}
#[derive(Debug, Error)]
pub enum GitCaptureAuthenticationError<E: std::error::Error + 'static> {
#[error(transparent)]
Capture(#[from] GitCaptureError),
#[error(transparent)]
Lineage(#[from] crate::WorkspaceLineageError<E>),
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitDiffCounts {
pub file_changes: usize,
pub binding_changes: usize,
}
pub async fn git_compatible_diff_counts<A, O>(
from: &Generation<A, O>,
to: &Generation<A, O>,
already_tracked: &BTreeSet<String>,
maximum_changes: u32,
cancellation: &CancellationToken,
) -> Result<GitDiffCounts, GitCaptureError>
where
A: AsyncAuthorityStore,
O: AsyncObjectStore,
{
let diff = from
.diff_to_bounded(to, maximum_changes, WorkBudget::UNBOUNDED, cancellation)
.await?;
let paths = diff
.changed_paths_bounded(maximum_changes, WorkBudget::UNBOUNDED, cancellation)
.await?
.value;
let mut before_policies = BTreeMap::new();
let mut after_policies = BTreeMap::new();
let mut files = BTreeSet::new();
let mut bindings = 0_usize;
for change in paths {
cancellation.check().map_err(WorkspaceError::from)?;
let path = portable_changed_path(&change.path)?;
let record = change.after.or(change.before);
let Some(record) = record else { continue };
if record.kind == FileKind::Directory {
continue;
}
let (generation, policies) = if change.after.is_some() {
(to, &mut after_policies)
} else {
(from, &mut before_policies)
};
if !git_path_eligible_at(generation, policies, &path, already_tracked).await? {
continue;
}
let comparable = |record: Option<crate::kernel::FileRecord>| {
record.map(|mut record| {
record.link_count = 0;
record
})
};
if comparable(change.before) != comparable(change.after) {
files.insert(record.file_id);
}
if change.before.map(|record| record.file_id) != change.after.map(|record| record.file_id) {
bindings = bindings.saturating_add(1);
}
}
Ok(GitDiffCounts {
file_changes: files.len(),
binding_changes: bindings,
})
}
async fn git_path_eligible_at<A, O>(
generation: &Generation<A, O>,
policies: &mut BTreeMap<String, GitIgnorePolicy>,
path: &str,
already_tracked: &BTreeSet<String>,
) -> Result<bool, GitCaptureError>
where
A: AsyncAuthorityStore,
O: AsyncObjectStore,
{
load_git_ignore_policy(generation, policies, "").await?;
let root = policies.get("").cloned().unwrap_or_default();
git_path_eligible_with_root_at(generation, policies, &root, path, false, already_tracked).await
}
async fn git_path_eligible_with_root_at<A, O>(
generation: &Generation<A, O>,
policies: &mut BTreeMap<String, GitIgnorePolicy>,
root: &GitIgnorePolicy,
path: &str,
is_directory: bool,
already_tracked: &BTreeSet<String>,
) -> Result<bool, GitCaptureError>
where
A: AsyncAuthorityStore,
O: AsyncObjectStore,
{
if already_tracked.contains(path) {
return Ok(true);
}
let mut directory = String::new();
let components = path.split('/').collect::<Vec<_>>();
for component in components.iter().take(components.len().saturating_sub(1)) {
load_git_ignore_policy(generation, policies, &directory).await?;
if !directory.is_empty() {
directory.push('/');
}
directory.push_str(component);
let prefix = format!("{directory}/");
let tracked_descendant = already_tracked
.range(prefix.clone()..)
.next()
.is_some_and(|candidate| candidate.starts_with(&prefix));
if !tracked_descendant && !git_path_eligible(root, policies, &directory, true, false) {
return Ok(false);
}
}
load_git_ignore_policy(generation, policies, &directory).await?;
Ok(git_path_eligible(root, policies, path, is_directory, false))
}
async fn load_git_ignore_policy<A, O>(
generation: &Generation<A, O>,
policies: &mut BTreeMap<String, GitIgnorePolicy>,
directory: &str,
) -> Result<(), GitCaptureError>
where
A: AsyncAuthorityStore,
O: AsyncObjectStore,
{
if policies.contains_key(directory) {
return Ok(());
}
let path = if directory.is_empty() {
"/.gitignore".to_owned()
} else {
format!("/{directory}/.gitignore")
};
let policy = match generation.read(&path, 1024 * 1024).await {
Ok(contents) => {
let contents =
std::str::from_utf8(&contents).map_err(|_| GitCaptureError::NonPortableName)?;
GitIgnorePolicy::parse(contents)
}
Err(WorkspaceError::NotFound) => GitIgnorePolicy::default(),
Err(error) => return Err(error.into()),
};
policies.insert(directory.to_owned(), policy);
Ok(())
}
async fn scan_git_capture_entries<A, O>(
live: &crate::Generation<A, O>,
) -> Result<(Vec<(String, bool)>, BTreeMap<String, GitIgnorePolicy>), GitCaptureError>
where
A: AsyncAuthorityStore,
O: AsyncObjectStore,
{
let mut entries = Vec::new();
let mut nested_policies = BTreeMap::new();
let mut pending = vec!["/".to_owned()];
while let Some(directory) = pending.pop() {
let mut after = None;
loop {
let page = live
.list_directory(&directory, after.as_ref(), 1_024)
.await?;
for entry in &page.entries {
let name = entry
.name
.unicode_text()
.ok_or(GitCaptureError::NonPortableName)?;
let path = if directory == "/" {
name.to_string()
} else {
format!("{}/{}", directory.trim_start_matches('/'), name)
};
let is_directory = entry.kind == FileKind::Directory;
entries.push((path.clone(), is_directory));
if is_directory {
pending.push(format!("/{path}"));
} else if name == ".gitignore" && directory != "/" {
let contents = live.read(&format!("/{path}"), 1024 * 1024).await?;
let contents = std::str::from_utf8(&contents)
.map_err(|_| GitCaptureError::NonPortableName)?;
nested_policies.insert(
directory.trim_start_matches('/').to_owned(),
GitIgnorePolicy::parse(contents),
);
}
}
if !page.has_more {
break;
}
after = page.entries.last().map(|entry| entry.name.clone());
}
}
Ok((entries, nested_policies))
}
pub async fn capture_git_compatible_generation<A, O>(
workspace: &Workspace<A, O>,
policy: &GitIgnorePolicy,
already_tracked: &BTreeSet<String>,
operation_id: OperationId,
) -> Result<GitCapturedGeneration<A, O>, GitCaptureError>
where
A: AsyncAuthorityStore,
O: AsyncObjectStore,
{
let live = workspace.head().await?;
capture_git_compatible_generation_from(workspace, live, policy, already_tracked, operation_id)
.await
}
pub async fn capture_git_compatible_generation_at<A, O>(
workspace: &Workspace<A, O>,
generation: GenerationId,
policy: &GitIgnorePolicy,
already_tracked: &BTreeSet<String>,
operation_id: OperationId,
) -> Result<GitCapturedGeneration<A, O>, GitCaptureError>
where
A: AsyncAuthorityStore,
O: AsyncObjectStore,
{
let live = workspace.generation(generation).await?;
capture_git_compatible_generation_from(workspace, live, policy, already_tracked, operation_id)
.await
}
async fn capture_git_compatible_generation_from<A, O>(
workspace: &Workspace<A, O>,
live: crate::Generation<A, O>,
policy: &GitIgnorePolicy,
already_tracked: &BTreeSet<String>,
operation_id: OperationId,
) -> Result<GitCapturedGeneration<A, O>, GitCaptureError>
where
A: AsyncAuthorityStore,
O: AsyncObjectStore,
{
let (mut entries, nested_policies) = scan_git_capture_entries(&live).await?;
entries.sort_by(|left, right| {
path_depth(&left.0)
.cmp(&path_depth(&right.0))
.then_with(|| left.0.cmp(&right.0))
});
let mut excluded = Vec::<String>::new();
let mut tracked_paths = BTreeSet::new();
for (path, is_directory) in &entries {
let parent_excluded = excluded.iter().any(|parent| is_path_below(path, parent));
let tracked = already_tracked.contains(path);
let tracked_descendant = *is_directory
&& already_tracked
.iter()
.any(|candidate| is_path_below(candidate, path));
if parent_excluded
|| (!git_path_eligible(policy, &nested_policies, path, *is_directory, tracked)
&& !tracked_descendant)
{
excluded.push(path.clone());
} else if !is_directory {
tracked_paths.insert(path.clone());
}
}
if excluded.is_empty() {
return Ok(GitCapturedGeneration {
generation: live,
initial_generation: None,
tracked_paths,
fork_parent: None,
});
}
let bytes = operation_id.into_bytes();
let workspace_hash = hex::encode(&workspace.id().into_bytes()[..6]);
let capture_name = format!("git-capture-{workspace_hash}-{}", hex::encode(bytes));
let capture = workspace
.fork(
capture_name,
crate::ForkOptions::from_generation(live.clone(), IdempotencyKey::from_bytes(bytes)),
)
.await?;
let capture_head = capture.head().await?;
let mut commit_bytes = bytes;
commit_bytes[0] ^= 0xa5;
let commit_key = IdempotencyKey::from_bytes(commit_bytes);
let (generation, initial_generation) = if let Some(generation) =
capture.operation_generation(commit_key).await?
{
let parents = generation.parents().await?;
let [initial_generation] = parents.as_slice() else {
return Err(GitCaptureError::Conflict);
};
(generation, *initial_generation)
} else {
let mut transaction = capture.begin_transaction(commit_key).await?;
for path in excluded.iter().rev() {
match transaction.remove(&format!("/{path}")).await {
Ok(()) | Err(WorkspaceError::NotFound) => {}
Err(error) => return Err(error.into()),
}
}
match transaction.commit().await? {
TransactionCommit::Committed(generation)
| TransactionCommit::AlreadyCommitted(generation) => (generation, capture_head.id()),
TransactionCommit::Conflict { .. }
| TransactionCommit::Fenced
| TransactionCommit::IdempotencyConflict => return Err(GitCaptureError::Conflict),
}
};
let pin = format!("capture-{}", hex::encode(bytes));
generation.pin(pin).await?;
Ok(GitCapturedGeneration {
generation,
initial_generation: Some(initial_generation),
tracked_paths,
fork_parent: Some(live),
})
}
#[allow(clippy::too_many_lines)] pub async fn capture_git_compatible_generation_incremental<A, O>(
workspace: &Workspace<A, O>,
previous_live: &Generation<A, O>,
previous_capture: &Generation<A, O>,
policy: &GitIgnorePolicy,
already_tracked: &BTreeSet<String>,
operation_id: OperationId,
) -> Result<GitCapturedGeneration<A, O>, GitCaptureError>
where
A: AsyncAuthorityStore,
O: AsyncObjectStore,
{
let live = workspace.head().await?;
let changes = previous_live.diff_to(&live, u32::MAX).await?;
let changed = changes.changed_paths(u32::MAX).await?;
let mut portable = Vec::with_capacity(changed.len());
for change in changed {
let path = portable_changed_path(&change.path)?;
if path.rsplit('/').next() == Some(".gitignore") {
return capture_git_compatible_generation(
workspace,
policy,
already_tracked,
operation_id,
)
.await;
}
portable.push((path, change.before, change.after));
}
let mut eligible = Vec::new();
let mut tracked_paths = already_tracked.clone();
let mut before_policies = BTreeMap::new();
let mut after_policies = BTreeMap::new();
for (path, before, after) in portable {
let kind = after.or(before).map(|record| record.kind);
let is_directory = kind == Some(FileKind::Directory);
let tracked = already_tracked.contains(&path);
let tracked_descendant = is_directory
&& already_tracked
.iter()
.any(|candidate| is_path_below(candidate, &path));
let (generation, policies) = if after.is_some() {
(&live, &mut after_policies)
} else {
(previous_live, &mut before_policies)
};
let allowed = git_path_eligible_with_root_at(
generation,
policies,
policy,
&path,
is_directory,
already_tracked,
)
.await?;
if tracked || tracked_descendant || allowed {
eligible.push(path.clone());
}
if !is_directory {
if after.is_some() && (tracked || allowed) {
tracked_paths.insert(path);
} else {
tracked_paths.remove(&path);
}
}
}
if eligible.is_empty() {
return Ok(GitCapturedGeneration {
generation: previous_capture.clone(),
initial_generation: None,
tracked_paths,
fork_parent: None,
});
}
let bytes = operation_id.into_bytes();
let workspace_hash = hex::encode(&previous_capture.workspace_id().into_bytes()[..6]);
let capture_name = format!("git-capture-{workspace_hash}-{}", hex::encode(bytes));
let capture = previous_capture
.workspace()
.fork(
capture_name,
crate::ForkOptions::from_generation(
previous_capture.clone(),
IdempotencyKey::from_bytes(bytes),
),
)
.await?;
let mut apply_bytes = bytes;
apply_bytes[0] ^= 0xa5;
let capture_head = capture.head().await?;
let (generation, initial_generation) = match capture
.apply_paths_from(
Some(previous_live),
Some(&live),
&eligible,
capture_head.id(),
IdempotencyKey::from_bytes(apply_bytes),
)
.await?
{
crate::WorkspacePathApply::Applied(generation)
| crate::WorkspacePathApply::NoChanges(generation) => (generation, capture_head.id()),
crate::WorkspacePathApply::AlreadyApplied(generation) => {
let parents = generation.parents().await?;
let [initial_generation] = parents.as_slice() else {
return Err(GitCaptureError::Conflict);
};
(generation, *initial_generation)
}
crate::WorkspacePathApply::Conflicted(_)
| crate::WorkspacePathApply::Stale(_)
| crate::WorkspacePathApply::Fenced
| crate::WorkspacePathApply::IdempotencyConflict => return Err(GitCaptureError::Conflict),
};
generation
.pin(format!("capture-{}", hex::encode(bytes)))
.await?;
Ok(GitCapturedGeneration {
generation,
initial_generation: Some(initial_generation),
tracked_paths,
fork_parent: Some(previous_capture.clone()),
})
}
fn portable_changed_path(path: &crate::kernel::NamespacePath) -> Result<String, GitCaptureError> {
let mut portable = String::new();
for component in path.components() {
if !portable.is_empty() {
portable.push('/');
}
portable.push_str(
&component
.unicode_text()
.ok_or(GitCaptureError::NonPortableName)?,
);
}
Ok(portable)
}
fn path_depth(path: &str) -> usize {
path.split('/').count()
}
fn is_path_below(candidate: &str, parent: &str) -> bool {
candidate
.strip_prefix(parent)
.is_some_and(|suffix| suffix.starts_with('/'))
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
struct GitIgnoreRule {
pattern: String,
negated: bool,
directory_only: bool,
anchored: bool,
}
impl GitIgnorePolicy {
pub async fn newly_ignored_paths_at<A, O>(
&self,
generation: &Generation<A, O>,
changes: &[crate::ChangedPath],
already_tracked: &BTreeSet<String>,
) -> Result<BTreeSet<String>, GitCaptureError>
where
A: AsyncAuthorityStore,
O: AsyncObjectStore,
{
let mut paths = BTreeSet::new();
let mut nested_policies = BTreeMap::new();
for change in changes {
let path = portable_changed_path(&change.path)?;
let is_directory = change
.after
.or(change.before)
.is_some_and(|record| record.kind == FileKind::Directory);
if is_directory {
continue;
}
if !git_path_eligible_with_root_at(
generation,
&mut nested_policies,
self,
&path,
is_directory,
already_tracked,
)
.await?
{
paths.insert(format!("/{path}"));
}
}
Ok(paths)
}
#[must_use]
pub fn parse(contents: &str) -> Self {
let rules = contents
.lines()
.filter_map(|line| {
let mut line = line.trim_end_matches('\r');
if line.is_empty() || line.starts_with('#') {
return None;
}
let negated = line.starts_with('!');
if negated {
line = line.strip_prefix('!').unwrap_or(line);
}
let anchored = line.starts_with('/');
if anchored {
line = line.strip_prefix('/').unwrap_or(line);
}
let directory_only = line.ends_with('/');
if directory_only {
line = line.trim_end_matches('/');
}
if line.is_empty() {
return None;
}
Some(GitIgnoreRule {
pattern: line.to_owned(),
negated,
directory_only,
anchored,
})
})
.collect();
Self { rules }
}
#[must_use]
pub fn eligible(&self, path: &str, is_directory: bool, already_tracked: bool) -> bool {
if already_tracked {
return true;
}
let path = path.trim_start_matches('/');
let mut ignored = false;
for rule in &self.rules {
if ignore_matches(rule, path, is_directory) {
ignored = !rule.negated;
}
}
!ignored
}
fn apply(&self, path: &str, is_directory: bool, ignored: &mut bool) {
for rule in &self.rules {
if ignore_matches(rule, path, is_directory) {
*ignored = !rule.negated;
}
}
}
}
fn git_path_eligible(
root: &GitIgnorePolicy,
nested: &BTreeMap<String, GitIgnorePolicy>,
path: &str,
is_directory: bool,
already_tracked: bool,
) -> bool {
if already_tracked {
return true;
}
let path = path.trim_start_matches('/');
let mut ignored = false;
root.apply(path, is_directory, &mut ignored);
let mut applicable = nested
.iter()
.filter_map(|(directory, policy)| {
path.strip_prefix(directory)
.and_then(|suffix| suffix.strip_prefix('/'))
.map(|relative| (directory.split('/').count(), relative, policy))
})
.collect::<Vec<_>>();
applicable.sort_by_key(|(depth, _, _)| *depth);
for (_, relative, policy) in applicable {
policy.apply(relative, is_directory, &mut ignored);
}
!ignored
}
fn ignore_matches(rule: &GitIgnoreRule, path: &str, is_directory: bool) -> bool {
if rule.directory_only {
let components: Vec<_> = path.split('/').collect();
let directory_count = if is_directory {
components.len()
} else {
components.len().saturating_sub(1)
};
return (1..=directory_count).any(|end| {
let prefix = components.get(..end).unwrap_or_default();
let directory = prefix.join("/");
if rule.anchored || rule.pattern.contains('/') {
wildcard_matches(&rule.pattern, &directory)
} else {
prefix
.iter()
.any(|component| wildcard_matches(&rule.pattern, component))
}
});
}
if rule.anchored || rule.pattern.contains('/') {
wildcard_matches(&rule.pattern, path)
} else {
path.split('/')
.any(|component| wildcard_matches(&rule.pattern, component))
}
}
fn wildcard_matches(pattern: &str, value: &str) -> bool {
let pattern = pattern.as_bytes();
let value = value.as_bytes();
let (mut pattern_index, mut value_index, mut star, mut checkpoint) = (0, 0, None, 0);
while value_index < value.len() {
if pattern.get(pattern_index).is_some_and(|byte| {
*byte == b'?' || value.get(value_index).is_some_and(|value| value == byte)
}) {
pattern_index += 1;
value_index += 1;
} else if pattern.get(pattern_index) == Some(&b'*') {
star = Some(pattern_index);
pattern_index += 1;
checkpoint = value_index;
} else if let Some(star_index) = star {
pattern_index = star_index + 1;
checkpoint += 1;
value_index = checkpoint;
} else {
return false;
}
}
while pattern.get(pattern_index) == Some(&b'*') {
pattern_index += 1;
}
pattern_index == pattern.len()
}
#[derive(Default)]
pub struct MemoryGitCompatStore {
states: Mutex<BTreeMap<WorkspaceId, GitCompatState>>,
}
impl MemoryGitCompatStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
#[derive(Debug, Error)]
#[error("Git compatibility memory store is unavailable")]
pub struct MemoryGitCompatStoreError;
impl GitCompatStore for MemoryGitCompatStore {
type Error = MemoryGitCompatStoreError;
async fn load(&self, workspace_id: WorkspaceId) -> Result<Option<GitCompatState>, Self::Error> {
self.states
.lock()
.map_err(|_| MemoryGitCompatStoreError)
.map(|states| states.get(&workspace_id).cloned())
}
async fn compare_and_swap(
&self,
workspace_id: WorkspaceId,
expected_revision: u64,
replacement: GitCompatState,
) -> Result<bool, Self::Error> {
let mut states = self.states.lock().map_err(|_| MemoryGitCompatStoreError)?;
let revision = states.get(&workspace_id).map_or(0, |state| state.revision);
if revision != expected_revision {
return Ok(false);
}
states.insert(workspace_id, replacement);
Ok(true)
}
async fn compare_and_delete(
&self,
workspace_id: WorkspaceId,
expected_revision: u64,
) -> Result<bool, Self::Error> {
let mut states = self.states.lock().map_err(|_| MemoryGitCompatStoreError)?;
let revision = states.get(&workspace_id).map_or(0, |state| state.revision);
if revision != expected_revision {
return Ok(false);
}
states.remove(&workspace_id);
Ok(true)
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::kernel::{FileMetadata, MetadataField};
use crate::{Digest, Fs, WorkspaceName};
#[derive(Debug, Error)]
#[error("test executor failure")]
struct TestExecutorError;
struct TestExecutor {
result: Result<GitFilesystemResult, TestExecutorError>,
operations: Mutex<Vec<OperationId>>,
}
impl TestExecutor {
fn returning(result: GitFilesystemResult) -> Self {
Self {
result: Ok(result),
operations: Mutex::new(Vec::new()),
}
}
fn failing() -> Self {
Self {
result: Err(TestExecutorError),
operations: Mutex::new(Vec::new()),
}
}
}
impl GitFilesystemExecutor for TestExecutor {
type Error = TestExecutorError;
async fn execute(
&self,
operation_id: OperationId,
_action: &GitFilesystemAction,
) -> Result<GitFilesystemResult, Self::Error> {
self.operations
.lock()
.expect("operation lock")
.push(operation_id);
self.result
.as_ref()
.map(Clone::clone)
.map_err(|_| TestExecutorError)
}
}
fn generation(byte: u8) -> GenerationId {
GenerationId::new(Digest::from_bytes([byte; 32]))
}
fn workspace() -> WorkspaceId {
WorkspaceId::derive(
[9; 16],
&WorkspaceName::new("repo").expect("valid test workspace"),
)
}
fn tree(byte: u8) -> GitTreeRef {
GitTreeRef::exact(workspace(), generation(byte))
}
#[test]
fn git_wire_identity_contract_matches_native_bytes() {
let operation = OperationId::from_bytes(std::array::from_fn(|index| {
u8::try_from(index).unwrap_or_default()
}));
let generation = GenerationId::new(Digest::from_bytes(std::array::from_fn(|index| {
u8::try_from(index).unwrap_or_default()
})));
let tree = GitTreeRef::exact(WorkspaceId::from_bytes(operation.into_bytes()), generation);
assert_eq!(
serde_json::to_value(GitTransitionId(operation)).expect("transition JSON"),
serde_json::json!("00010203-0405-0607-0809-0a0b0c0d0e0f")
);
assert_eq!(
serde_json::to_value(GitCaptureProof {
fork_parent: tree,
initial_generation: generation,
operation_id: operation,
})
.expect("proof JSON"),
serde_json::json!({
"fork_parent": {
"kind": "exact",
"workspace_id": (0u8..16).collect::<Vec<_>>(),
"generation": (0u8..32).collect::<Vec<_>>()
},
"initial_generation": (0u8..32).collect::<Vec<_>>(),
"operation_id": "00010203-0405-0607-0809-0a0b0c0d0e0f"
})
);
}
#[tokio::test]
async fn foreign_workspace_tree_is_rejected_before_executor_side_effects() {
let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
let foreign_workspace = WorkspaceId::derive(
[8; 16],
&WorkspaceName::new("foreign").expect("valid foreign workspace"),
);
let executor = TestExecutor::returning(GitFilesystemResult::Applied {
tree: None,
tracked_paths: None,
});
let error = repository
.run(
GitCommand::Status,
GitTreeRef::exact(foreign_workspace, generation(1)),
&executor,
)
.await
.expect_err("foreign tree must fail closed");
assert!(matches!(
error,
GitCompatRunError::Compat(GitCompatError::WorkspaceMismatch)
));
assert!(
executor
.operations
.lock()
.expect("operation lock")
.is_empty(),
"foreign trees must never reach the filesystem executor"
);
}
#[tokio::test]
async fn foreign_completion_tree_is_rejected_without_consuming_the_transition() {
let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
let GitCommandOutput::Prepared { transition, .. } = repository
.execute(
GitCommand::Commit {
message: "initial".to_owned(),
author: "agent".to_owned(),
authored_at_seconds: 10,
},
generation(1),
)
.await
.expect("prepare commit")
else {
panic!("expected prepared commit");
};
let foreign = WorkspaceId::derive(
[8; 16],
&WorkspaceName::new("foreign-result").expect("valid workspace"),
);
assert!(matches!(
repository
.complete_transition_result(
transition,
&GitFilesystemResult::Captured {
tree: GitTreeRef::exact(foreign, generation(1)),
tracked_paths: BTreeSet::new(),
proof: None,
},
)
.await,
Err(GitCompatError::WorkspaceMismatch)
));
assert_eq!(
repository
.pending_transition()
.await
.expect("pending transition")
.map(|pending| pending.id),
Some(transition)
);
}
#[tokio::test]
async fn persisted_commit_hashes_are_validated_before_use() {
let store = MemoryGitCompatStore::new();
let mut state = GitCompatState::new("main", workspace());
let mut commit = GitCommit::new(tree(1), Vec::new(), "agent", 10, "initial");
commit.message = "forged".to_owned();
state.revision = 1;
state.commits.insert(commit.id, commit.clone());
state.branches.get_mut("main").expect("main branch").head = Some(commit.id);
{
let mut states = store.states.lock().expect("state lock");
states.insert(workspace(), state);
}
let repository = GitCompatRepository::new(workspace(), store);
assert!(matches!(
repository.execute(GitCommand::Status, generation(1)).await,
Err(GitCompatError::InvalidState)
));
}
#[tokio::test]
async fn persisted_foreign_commit_tree_needs_capture_provenance() {
let store = MemoryGitCompatStore::new();
let mut state = GitCompatState::new("main", workspace());
let foreign = WorkspaceId::derive(
[8; 16],
&WorkspaceName::new("foreign-persisted-tree").expect("workspace name"),
);
let commit = GitCommit::new_with_metadata(
GitTreeRef::exact(foreign, generation(7)),
tree(1),
BTreeSet::from(["foreign.txt".to_owned()]),
Vec::new(),
"agent",
10,
"forged tree",
);
state.revision = 1;
state.branches.get_mut("main").expect("branch").head = Some(commit.id);
state
.branches
.get_mut("main")
.expect("branch")
.tracked_paths = commit.tracked_paths.clone();
state.commits.insert(commit.id, commit);
store
.states
.lock()
.expect("state lock")
.insert(workspace(), state);
let repository = GitCompatRepository::new(workspace(), store);
assert!(matches!(
repository.execute(GitCommand::Status, generation(1)).await,
Err(GitCompatError::InvalidState)
));
}
#[test]
fn generated_commit_uses_exact_resulting_tracked_paths() {
let mut state = GitCompatState::new("main", workspace());
state.current_mut().expect("current branch").tracked_paths =
BTreeSet::from(["deleted.txt".to_owned(), "kept.txt".to_owned()]);
let output = complete_pending::<MemoryGitCompatStoreError>(
&mut state,
GitPendingMutation::ApplyCommit {
commit: GitCommitId::from_bytes([7; 32]),
reverse: false,
},
&GitFilesystemResult::Applied {
tree: Some(tree(2)),
tracked_paths: Some(BTreeSet::from(["kept.txt".to_owned()])),
},
)
.expect("generated commit");
let GitCommandOutput::Committed(commit) = output else {
panic!("expected generated commit");
};
assert_eq!(
commit.tracked_paths,
BTreeSet::from(["kept.txt".to_owned()])
);
assert_eq!(
state.current().expect("current branch").tracked_paths,
commit.tracked_paths
);
}
#[tokio::test]
async fn auto_staging_commit_and_machine_readable_history() {
let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
let GitCommandOutput::Status(status) = repository
.execute(GitCommand::Status, generation(1))
.await
.expect("status")
else {
panic!("expected status");
};
assert_eq!(status.dirty, GitDirtyState::Dirty);
assert!(status.all_changes_staged);
let GitCommandOutput::Prepared {
transition,
action: GitFilesystemAction::CaptureCommit { .. },
} = repository
.execute(
GitCommand::Commit {
message: "initial".to_owned(),
author: "agent".to_owned(),
authored_at_seconds: 10,
},
generation(1),
)
.await
.expect("capture commit")
else {
panic!("expected commit capture");
};
let GitCommandOutput::Committed(commit) = repository
.complete_transition_result(
transition,
&GitFilesystemResult::Captured {
tree: tree(1),
tracked_paths: BTreeSet::from(["tracked.txt".to_owned()]),
proof: None,
},
)
.await
.expect("record commit")
else {
panic!("expected commit");
};
let GitCommandOutput::Commits(log) = repository
.execute(GitCommand::Log { maximum: 10 }, generation(1))
.await
.expect("log")
else {
panic!("expected log");
};
assert_eq!(log, vec![commit]);
}
#[tokio::test]
async fn composed_run_finalizes_commit_and_branch_actions() {
let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
let commit_executor = TestExecutor::returning(GitFilesystemResult::Captured {
tree: tree(9),
tracked_paths: BTreeSet::from(["tracked.txt".to_owned()]),
proof: None,
});
let GitCommandOutput::Committed(commit) = repository
.run(
GitCommand::Commit {
message: "initial".to_owned(),
author: "agent".to_owned(),
authored_at_seconds: 10,
},
generation(1),
&commit_executor,
)
.await
.expect("composed commit")
else {
panic!("expected composed commit");
};
assert_eq!(commit.tree, tree(9));
assert_eq!(commit.workspace_tree, tree(1));
assert!(matches!(
repository
.execute(GitCommand::Status, generation(1))
.await
.expect("status"),
GitCommandOutput::Status(GitStatus {
dirty: GitDirtyState::Clean,
..
})
));
let child = WorkspaceId::derive(
[9; 16],
&WorkspaceName::new("feature-run").expect("valid workspace name"),
);
let branch_executor = TestExecutor::returning(GitFilesystemResult::Forked {
workspace_id: child,
});
repository
.run(
GitCommand::Branch {
create: Some("feature-run".to_owned()),
},
generation(1),
&branch_executor,
)
.await
.expect("composed branch");
assert!(matches!(
repository
.execute(GitCommand::Branch { create: None }, generation(1))
.await
.expect("branches"),
GitCommandOutput::Branches { branches, .. }
if branches.iter().any(|branch| branch.workspace_id == child)
));
}
#[tokio::test]
async fn composed_run_leaves_failed_transition_for_exact_resume() {
let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
let commit_executor = TestExecutor::returning(GitFilesystemResult::Captured {
tree: tree(1),
tracked_paths: BTreeSet::new(),
proof: None,
});
repository
.run(
GitCommand::Commit {
message: "initial".to_owned(),
author: "agent".to_owned(),
authored_at_seconds: 10,
},
generation(1),
&commit_executor,
)
.await
.expect("initial commit");
let child = WorkspaceId::derive(
[9; 16],
&WorkspaceName::new("recover-run").expect("valid workspace name"),
);
repository
.run(
GitCommand::Branch {
create: Some("recover-run".to_owned()),
},
generation(1),
&TestExecutor::returning(GitFilesystemResult::Forked {
workspace_id: child,
}),
)
.await
.expect("branch");
let failed = repository
.run(
GitCommand::Switch {
branch: "recover-run".to_owned(),
create: false,
},
generation(2),
&TestExecutor::failing(),
)
.await;
assert!(matches!(failed, Err(GitCompatRunError::Executor(_))));
let pending = repository
.pending_transition()
.await
.expect("pending transition")
.expect("durable pending transition");
let executor = TestExecutor::returning(GitFilesystemResult::Applied {
tree: Some(tree(1)),
tracked_paths: None,
});
assert!(
repository
.resume(&executor)
.await
.expect("resume")
.is_some()
);
assert!(
repository
.pending_transition()
.await
.expect("pending cleared")
.is_none()
);
assert_eq!(
executor
.operations
.lock()
.expect("operation lock")
.as_slice(),
&[pending.id.operation_id()]
);
}
#[tokio::test]
async fn conflicted_merge_can_continue_or_abort_without_a_second_sequencer() {
async fn pending_merge(
repository: &GitCompatRepository<MemoryGitCompatStore>,
child: WorkspaceId,
) {
repository
.run(
GitCommand::Branch {
create: Some("feature".to_owned()),
},
generation(1),
&TestExecutor::returning(GitFilesystemResult::Forked {
workspace_id: child,
}),
)
.await
.expect("feature branch");
assert!(matches!(
repository
.run(
GitCommand::Merge {
branch: "feature".to_owned(),
},
generation(2),
&TestExecutor::failing(),
)
.await,
Err(GitCompatRunError::Executor(_))
));
}
let child = WorkspaceId::derive(
[9; 16],
&WorkspaceName::new("merge-control").expect("valid workspace name"),
);
let continued = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
pending_merge(&continued, child).await;
assert_eq!(
continued
.run(
GitCommand::Add {
paths: vec!["conflicted.txt".to_owned()],
},
generation(3),
&TestExecutor::returning(GitFilesystemResult::Applied {
tree: None,
tracked_paths: None,
}),
)
.await
.expect("stage conflict resolution"),
GitCommandOutput::NoOp
);
assert!(
continued
.pending_transition()
.await
.expect("merge remains pending after add")
.is_some()
);
assert!(matches!(
continued
.run(
GitCommand::MergeContinue,
generation(3),
&TestExecutor::returning(GitFilesystemResult::Applied {
tree: None,
tracked_paths: None,
}),
)
.await
.expect("continue merge"),
GitCommandOutput::Committed(GitCommit { tree: GitTreeRef::Exact(tree), .. })
if tree.generation == generation(3)
));
assert!(
continued
.pending_transition()
.await
.expect("continued state")
.is_none()
);
let aborted = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
pending_merge(&aborted, child).await;
let executor = TestExecutor::returning(GitFilesystemResult::Applied {
tree: Some(tree(2)),
tracked_paths: None,
});
assert!(matches!(
aborted
.run(GitCommand::MergeAbort, generation(4), &executor)
.await
.expect("abort merge"),
GitCommandOutput::Filesystem(GitFilesystemResult::Applied { .. })
));
assert!(
aborted
.pending_transition()
.await
.expect("aborted state")
.is_none()
);
}
#[tokio::test]
async fn capture_omits_newly_ignored_paths_but_keeps_tracked_descendants() {
let fs = Fs::memory();
let workspace = fs
.create_workspace("git-capture-policy")
.await
.expect("workspace");
let mut transaction = workspace
.begin_transaction(IdempotencyKey::new())
.await
.expect("transaction");
transaction
.create_dir_all("/target/nested")
.await
.expect("directories");
transaction
.write_text("/target/drop.bin", "drop")
.await
.expect("ignored file");
transaction
.write_text("/target/nested/keep.txt", "keep")
.await
.expect("tracked file");
transaction.commit().await.expect("commit fixture");
let captured = capture_git_compatible_generation(
&workspace,
&GitIgnorePolicy::parse("target/\n"),
&BTreeSet::from(["target/nested/keep.txt".to_owned()]),
OperationId::from_bytes([0x31; 16]),
)
.await
.expect("capture");
assert!(captured.generation.stat("/target/drop.bin").await.is_err());
assert!(
captured
.generation
.stat("/target/nested/keep.txt")
.await
.is_ok()
);
assert_eq!(
captured.tracked_paths,
BTreeSet::from(["target/nested/keep.txt".to_owned()])
);
let recovered = capture_git_compatible_generation(
&workspace,
&GitIgnorePolicy::parse("target/\n"),
&BTreeSet::from(["target/nested/keep.txt".to_owned()]),
OperationId::from_bytes([0x31; 16]),
)
.await
.expect("recover capture");
assert_eq!(recovered.generation.id(), captured.generation.id());
}
#[tokio::test]
async fn capture_at_generation_excludes_later_workspace_updates() {
let fs = Fs::memory();
let workspace = fs
.create_workspace("git-capture-pinned-generation")
.await
.expect("workspace");
let mut initial = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x35; 16]))
.await
.expect("initial transaction");
initial
.write_text("/published.txt", "published")
.await
.expect("published file");
let TransactionCommit::Committed(published) =
initial.commit().await.expect("initial commit")
else {
panic!("initial transaction did not commit");
};
let mut later = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x36; 16]))
.await
.expect("later transaction");
later
.write_text("/later.txt", "later")
.await
.expect("later file");
later.commit().await.expect("later commit");
let captured = capture_git_compatible_generation_at(
&workspace,
published.id(),
&GitIgnorePolicy::default(),
&BTreeSet::new(),
OperationId::from_bytes([0x37; 16]),
)
.await
.expect("pinned capture");
assert!(captured.generation.stat("/published.txt").await.is_ok());
assert!(captured.generation.stat("/later.txt").await.is_err());
}
#[tokio::test]
async fn capture_preserves_metadata_and_hard_links_through_public_fork_primitives() {
let fs = Fs::memory();
let workspace = fs
.create_workspace("git-capture-records")
.await
.expect("workspace");
let mut transaction = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x41; 16]))
.await
.expect("transaction");
transaction
.write_text("/source.txt", "linked")
.await
.expect("source");
transaction
.set_metadata(
"/source.txt",
FileMetadata {
posix_mode: MetadataField::Value(0o100_640),
..FileMetadata::default()
},
)
.await
.expect("metadata");
transaction
.hard_link("/source.txt", "/linked.txt")
.await
.expect("hard link");
transaction
.create_dir_all("/ignored")
.await
.expect("ignored directory");
transaction
.write_text("/ignored/drop.txt", "drop")
.await
.expect("ignored file");
transaction.commit().await.expect("commit fixture");
let captured = capture_git_compatible_generation(
&workspace,
&GitIgnorePolicy::parse("ignored/\n"),
&BTreeSet::new(),
OperationId::from_bytes([0x42; 16]),
)
.await
.expect("capture");
let source = captured
.generation
.stat("/source.txt")
.await
.expect("source stat");
let linked = captured
.generation
.stat("/linked.txt")
.await
.expect("link stat");
assert_eq!(source.file_id, linked.file_id);
assert_eq!(source.link_count, 2);
assert_eq!(source.metadata.posix_mode, Some(0o100_640));
assert!(captured.generation.stat("/ignored").await.is_err());
}
#[tokio::test]
async fn exact_capture_inherits_nested_ignore_rules_with_local_negation() {
let fs = Fs::memory();
let workspace = fs
.create_workspace("git-capture-nested-policy")
.await
.expect("workspace");
let mut transaction = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x49; 16]))
.await
.expect("transaction");
transaction.create_dir_all("/src").await.expect("directory");
transaction
.write_text("/src/.gitignore", "*.tmp\n!keep.tmp\n")
.await
.expect("nested policy");
transaction
.write_text("/src/drop.tmp", "drop")
.await
.expect("ignored file");
transaction
.write_text("/src/keep.tmp", "keep")
.await
.expect("negated file");
transaction
.write_text("/outside.tmp", "outside")
.await
.expect("outside file");
transaction.commit().await.expect("fixture");
let captured = capture_git_compatible_generation(
&workspace,
&GitIgnorePolicy::default(),
&BTreeSet::new(),
OperationId::from_bytes([0x4a; 16]),
)
.await
.expect("capture");
assert!(captured.generation.stat("/src/drop.tmp").await.is_err());
assert!(captured.generation.stat("/src/keep.tmp").await.is_ok());
assert!(captured.generation.stat("/outside.tmp").await.is_ok());
}
#[tokio::test]
async fn diff_uses_nested_ignores_without_creating_a_capture_workspace() {
let fs = Fs::memory();
let workspace = fs
.create_workspace("git-diff-policy")
.await
.expect("workspace");
let before = workspace.head().await.expect("baseline");
let mut transaction = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x4b; 16]))
.await
.expect("transaction");
transaction.create_dir_all("/src").await.expect("directory");
transaction
.write_text("/src/.gitignore", "*.tmp\n!keep.tmp\n")
.await
.expect("ignore policy");
transaction
.write_text("/src/drop.tmp", "drop")
.await
.expect("ignored");
transaction
.write_text("/src/keep.tmp", "keep")
.await
.expect("kept");
transaction.commit().await.expect("commit");
let after = workspace.head().await.expect("changed generation");
let counts = git_compatible_diff_counts(
&before,
&after,
&BTreeSet::new(),
100,
&CancellationToken::new(),
)
.await
.expect("read-only diff");
assert_eq!(counts.binding_changes, 2);
assert_eq!(counts.file_changes, 2);
assert_eq!(
workspace.head().await.expect("unchanged head").id(),
after.id()
);
let tracked_counts = git_compatible_diff_counts(
&before,
&after,
&BTreeSet::from(["src/drop.tmp".to_owned()]),
100,
&CancellationToken::new(),
)
.await
.expect("tracked override");
assert_eq!(tracked_counts.binding_changes, 3);
assert_eq!(tracked_counts.file_changes, 3);
}
#[tokio::test]
async fn diff_uses_the_source_ignore_policy_for_deleted_paths() {
let fs = Fs::memory();
let workspace = fs
.create_workspace("git-diff-deleted-policy")
.await
.expect("workspace");
let mut initial = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x4c; 16]))
.await
.expect("initial transaction");
initial
.write_text("/drop.tmp", "old")
.await
.expect("source file");
initial.commit().await.expect("initial commit");
let before = workspace.head().await.expect("before");
let mut changed = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x4d; 16]))
.await
.expect("changed transaction");
changed
.write_text("/.gitignore", "*.tmp\n")
.await
.expect("new ignore policy");
changed.remove("/drop.tmp").await.expect("delete old file");
changed.commit().await.expect("changed commit");
let after = workspace.head().await.expect("after");
let counts = git_compatible_diff_counts(
&before,
&after,
&BTreeSet::new(),
100,
&CancellationToken::new(),
)
.await
.expect("diff");
assert_eq!(counts.binding_changes, 2);
assert_eq!(counts.file_changes, 2);
}
#[tokio::test]
async fn ignored_ancestor_cannot_be_reincluded_by_nested_negation() {
let fs = Fs::memory();
let workspace = fs
.create_workspace("git-ignored-parent")
.await
.expect("workspace");
let before = workspace.head().await.expect("before");
let mut transaction = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x4e; 16]))
.await
.expect("transaction");
transaction
.write_text("/.gitignore", "build/\n")
.await
.expect("root ignore");
transaction
.create_dir_all("/build")
.await
.expect("directory");
transaction
.write_text("/build/.gitignore", "!keep.txt\n")
.await
.expect("nested negation");
transaction
.write_text("/build/keep.txt", "ignored")
.await
.expect("file");
transaction.commit().await.expect("commit");
let after = workspace.head().await.expect("after");
let counts = git_compatible_diff_counts(
&before,
&after,
&BTreeSet::new(),
100,
&CancellationToken::new(),
)
.await
.expect("diff");
assert_eq!(counts.file_changes, 1, "only root .gitignore is eligible");
assert_eq!(counts.binding_changes, 1);
let captured = capture_git_compatible_generation(
&workspace,
&GitIgnorePolicy::parse("build/\n"),
&BTreeSet::new(),
OperationId::from_bytes([0x4f; 16]),
)
.await
.expect("capture");
assert!(captured.generation.stat("/build/keep.txt").await.is_err());
}
#[tokio::test]
async fn incremental_capture_honors_unchanged_nested_ignore() {
let fs = Fs::memory();
let workspace = fs
.create_workspace("git-incremental-nested")
.await
.expect("workspace");
let mut initial = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x65; 16]))
.await
.expect("transaction");
initial.create_dir_all("/src").await.expect("directory");
initial
.write_text("/src/.gitignore", "*.tmp\n")
.await
.expect("ignore");
initial
.write_text("/src/kept.txt", "before")
.await
.expect("kept");
let TransactionCommit::Committed(previous_live) = initial.commit().await.expect("commit")
else {
panic!("initial commit did not publish");
};
let previous_capture = capture_git_compatible_generation(
&workspace,
&GitIgnorePolicy::default(),
&BTreeSet::new(),
OperationId::from_bytes([0x66; 16]),
)
.await
.expect("capture");
let mut changed = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x67; 16]))
.await
.expect("transaction");
changed
.write_text("/src/new.tmp", "ignored")
.await
.expect("ignored");
changed
.write_text("/src/kept.txt", "after")
.await
.expect("edit");
changed.commit().await.expect("commit");
let captured = capture_git_compatible_generation_incremental(
&workspace,
&previous_live,
&previous_capture.generation,
&GitIgnorePolicy::default(),
&previous_capture.tracked_paths,
OperationId::from_bytes([0x68; 16]),
)
.await
.expect("incremental capture");
assert!(captured.generation.stat("/src/new.tmp").await.is_err());
assert_eq!(
captured
.generation
.read("/src/kept.txt", 64)
.await
.expect("kept")
.as_ref(),
b"after"
);
assert!(!captured.tracked_paths.contains("src/new.tmp"));
}
#[tokio::test]
async fn newly_ignored_merge_paths_exclude_tracked_changes() {
let fs = Fs::memory();
let workspace = fs
.create_workspace("git-ignored-merge-paths")
.await
.expect("workspace");
let mut initial = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x70; 16]))
.await
.expect("initial transaction");
initial.create_dir_all("/sub").await.expect("subdirectory");
initial
.write_text("/sub/.gitignore", "*.tmp\n")
.await
.expect("nested ignore");
initial.commit().await.expect("initial commit");
let before = workspace.head().await.expect("before");
workspace
.write_text("/scratch.tmp", "child")
.await
.expect("child change");
workspace
.write_text("/sub/cache.tmp", "child")
.await
.expect("nested child change");
let mut build = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x71; 16]))
.await
.expect("build transaction");
build
.create_dir_all("/build")
.await
.expect("build directory");
build
.write_text("/build/output.bin", "generated")
.await
.expect("generated file");
build.commit().await.expect("build commit");
let after = workspace.head().await.expect("after");
let changes = before
.diff_to(&after, 16)
.await
.expect("diff")
.changed_paths(16)
.await
.expect("changed paths");
let policy = GitIgnorePolicy::parse("*.tmp\nbuild/\n");
assert_eq!(
policy
.newly_ignored_paths_at(&after, &changes, &BTreeSet::new())
.await
.expect("untracked policy"),
BTreeSet::from([
"/scratch.tmp".to_owned(),
"/sub/cache.tmp".to_owned(),
"/build/output.bin".to_owned(),
])
);
assert!(
policy
.newly_ignored_paths_at(
&after,
&changes,
&BTreeSet::from([
"scratch.tmp".to_owned(),
"sub/cache.tmp".to_owned(),
"build/output.bin".to_owned(),
]),
)
.await
.expect("tracked policy")
.is_empty()
);
}
#[tokio::test]
async fn filtered_capture_retry_keeps_original_fork_generation() {
let fs = Fs::memory();
let workspace = fs
.create_workspace("git-capture-retry")
.await
.expect("workspace");
let mut transaction = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x69; 16]))
.await
.expect("transaction");
transaction
.write_text("/keep.txt", "keep")
.await
.expect("kept");
transaction
.write_text("/drop.tmp", "drop")
.await
.expect("ignored");
transaction.commit().await.expect("commit");
let operation_id = OperationId::from_bytes([0x6a; 16]);
let policy = GitIgnorePolicy::parse("*.tmp\n");
let first =
capture_git_compatible_generation(&workspace, &policy, &BTreeSet::new(), operation_id)
.await
.expect("first capture");
let retry =
capture_git_compatible_generation(&workspace, &policy, &BTreeSet::new(), operation_id)
.await
.expect("retry");
assert_eq!(first.generation.id(), retry.generation.id());
assert_eq!(first.initial_generation, retry.initial_generation);
assert_ne!(first.initial_generation, Some(first.generation.id()));
}
#[tokio::test]
async fn filtered_commit_completes_and_clears_its_transition() {
let fs = Fs::memory();
let workspace = fs
.create_workspace("git-filtered-commit")
.await
.expect("workspace");
let mut transaction = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x6b; 16]))
.await
.expect("transaction");
transaction
.write_text("/keep.txt", "keep")
.await
.expect("kept");
transaction
.write_text("/drop.tmp", "drop")
.await
.expect("ignored");
transaction.commit().await.expect("commit");
let live = workspace.head().await.expect("live");
let repository = GitCompatRepository::new(workspace.id(), MemoryGitCompatStore::new());
let GitCommandOutput::Prepared {
transition,
action: GitFilesystemAction::CaptureCommit { .. },
} = repository
.execute(
GitCommand::Commit {
message: "filtered".to_owned(),
author: "agent".to_owned(),
authored_at_seconds: 1,
},
live.id(),
)
.await
.expect("prepare")
else {
panic!("expected capture transition");
};
let captured = capture_git_compatible_generation(
&workspace,
&GitIgnorePolicy::parse("*.tmp\n"),
&BTreeSet::new(),
transition.operation_id(),
)
.await
.expect("capture");
let proof = captured
.verified_proof(transition.operation_id())
.await
.expect("verified proof");
let result = GitFilesystemResult::Captured {
tree: GitTreeRef::exact(
captured.generation.workspace().id(),
captured.generation.id(),
),
tracked_paths: captured.tracked_paths,
proof,
};
let GitCommandOutput::Committed(commit) = repository
.complete_transition_result(transition, &result)
.await
.expect("complete")
else {
panic!("expected commit");
};
assert!(
repository
.pending_transition()
.await
.expect("pending")
.is_none()
);
assert!(commit.tracked_paths.contains("keep.txt"));
assert!(!commit.tracked_paths.contains("drop.tmp"));
let mut ignored_only = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x6c; 16]))
.await
.expect("transaction");
ignored_only
.write_text("/new.tmp", "not committed")
.await
.expect("ignored file");
ignored_only.commit().await.expect("workspace change");
let live_after = workspace.head().await.expect("live after ignored change");
let GitCommandOutput::Prepared { transition, .. } = repository
.execute(
GitCommand::Commit {
message: "ignored only".to_owned(),
author: "agent".to_owned(),
authored_at_seconds: 2,
},
live_after.id(),
)
.await
.expect("prepare ignored-only commit")
else {
panic!("expected prepared transition");
};
let ignored_capture = capture_git_compatible_generation_incremental(
&workspace,
&live,
&captured.generation,
&GitIgnorePolicy::parse("*.tmp\n"),
&commit.tracked_paths,
transition.operation_id(),
)
.await
.expect("ignored-only capture");
assert_eq!(ignored_capture.generation.id(), captured.generation.id());
assert!(matches!(
repository
.complete_transition_result(
transition,
&GitFilesystemResult::Captured {
tree: GitTreeRef::exact(
ignored_capture.generation.workspace_id(),
ignored_capture.generation.id(),
),
tracked_paths: ignored_capture.tracked_paths,
proof: None,
},
)
.await,
Err(GitCompatError::NothingToCommit)
));
assert!(
repository
.pending_transition()
.await
.expect("pending")
.is_none()
);
}
#[tokio::test]
async fn incremental_capture_applies_only_changed_eligible_paths() {
let fs = Fs::memory();
let workspace = fs
.create_workspace("git-capture-incremental")
.await
.expect("workspace");
let mut transaction = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x51; 16]))
.await
.expect("transaction");
transaction
.write_text("/kept.txt", "before")
.await
.expect("tracked file");
transaction
.write_text("/unchanged.txt", "same")
.await
.expect("unchanged file");
transaction
.write_text("/previous.tmp", "ignored before capture")
.await
.expect("ignored fixture");
let TransactionCommit::Committed(previous_live) =
transaction.commit().await.expect("base commit")
else {
panic!("base fixture did not commit");
};
let policy = GitIgnorePolicy::parse("*.tmp\n");
let previous_capture = capture_git_compatible_generation(
&workspace,
&policy,
&BTreeSet::new(),
OperationId::from_bytes([0x52; 16]),
)
.await
.expect("base capture");
let state_root = tempfile::tempdir().expect("lineage state root");
let state_store = crate::LocalCoreStateStore::new(state_root.path());
let graph = crate::WorkspaceGraph::new(state_store.clone());
previous_capture
.authenticated_proof(OperationId::from_bytes([0x52; 16]), &graph)
.await
.expect("durable first capture lineage")
.expect("filtered first capture proof");
let mut transaction = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x53; 16]))
.await
.expect("transaction");
transaction
.write_text("/kept.txt", "after")
.await
.expect("edit");
transaction
.write_text("/new.tmp", "ignored")
.await
.expect("ignored addition");
transaction.commit().await.expect("live edit");
let captured = capture_git_compatible_generation_incremental(
&workspace,
&previous_live,
&previous_capture.generation,
&policy,
&previous_capture.tracked_paths,
OperationId::from_bytes([0x54; 16]),
)
.await
.expect("incremental capture");
captured
.authenticated_proof(OperationId::from_bytes([0x54; 16]), &graph)
.await
.expect("durable incremental lineage")
.expect("filtered incremental proof");
let lineage = graph
.resolve(captured.generation.workspace_id())
.await
.expect("reopen incremental lineage");
assert_eq!(
lineage.parent_workspace_id,
Some(previous_capture.generation.workspace_id()),
"incremental capture must fork from the prior filtered capture"
);
let reopened = crate::DistributedFs::new(fs.clone(), state_store)
.workspace(captured.generation.workspace_id())
.await
.expect("reopen filtered capture from durable lineage");
reopened
.generation(captured.generation.id())
.await
.expect("reopen captured generation");
assert_eq!(
captured
.generation
.read("/kept.txt", 64)
.await
.expect("edited file")
.as_ref(),
b"after"
);
assert_eq!(
captured
.generation
.read("/unchanged.txt", 64)
.await
.expect("unchanged file")
.as_ref(),
b"same"
);
assert!(captured.generation.stat("/new.tmp").await.is_err());
assert!(captured.generation.stat("/previous.tmp").await.is_err());
assert_eq!(
captured.tracked_paths,
BTreeSet::from(["kept.txt".to_owned(), "unchanged.txt".to_owned()])
);
}
#[tokio::test]
async fn nested_gitignore_change_forces_exact_capture() {
let fs = Fs::memory();
let workspace = fs
.create_workspace("git-capture-nested-ignore")
.await
.expect("workspace");
let mut transaction = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x61; 16]))
.await
.expect("transaction");
transaction.create_dir_all("/src").await.expect("directory");
transaction
.write_text("/src/ignored.txt", "present before ignore")
.await
.expect("file");
let TransactionCommit::Committed(previous_live) =
transaction.commit().await.expect("base commit")
else {
panic!("base fixture did not commit");
};
let previous_capture = capture_git_compatible_generation(
&workspace,
&GitIgnorePolicy::default(),
&BTreeSet::new(),
OperationId::from_bytes([0x62; 16]),
)
.await
.expect("base capture");
let mut transaction = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x63; 16]))
.await
.expect("transaction");
transaction
.write_text("/src/.gitignore", "ignored.txt\n")
.await
.expect("nested ignore");
transaction.commit().await.expect("live edit");
let captured = capture_git_compatible_generation_incremental(
&workspace,
&previous_live,
&previous_capture.generation,
&GitIgnorePolicy::parse("src/ignored.txt\n"),
&BTreeSet::new(),
OperationId::from_bytes([0x64; 16]),
)
.await
.expect("capture");
assert!(captured.generation.stat("/src/ignored.txt").await.is_err());
assert!(captured.generation.stat("/src/.gitignore").await.is_ok());
}
#[tokio::test]
async fn tree_walk_and_grep_are_generation_bound_and_portable() {
let fs = crate::Fs::memory();
let workspace = fs
.create_workspace("git-inspection")
.await
.expect("workspace");
let mut transaction = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x32; 16]))
.await
.expect("transaction");
transaction.create_dir_all("/src").await.expect("directory");
transaction
.write_text("/src/a.txt", "one\nneedle\n")
.await
.expect("first file");
transaction
.write_text("/src/b.txt", "needle two\n")
.await
.expect("second file");
transaction
.write_text("/src/large.txt", "needle is too large")
.await
.expect("large file");
transaction
.write(
"/src/binary.bin",
bytes::Bytes::from_static(b"needle\xffbinary"),
)
.await
.expect("binary file");
let TransactionCommit::Committed(generation) = transaction.commit().await.expect("commit")
else {
panic!("fixture did not commit");
};
let entries = walk_git_tree(&generation, Some("src"), 10)
.await
.expect("walk");
assert_eq!(
entries
.iter()
.map(|entry| entry.path.as_str())
.collect::<Vec<_>>(),
vec!["src/a.txt", "src/b.txt", "src/binary.bin", "src/large.txt"]
);
let result = grep_git_generation(&generation, "needle", Some("src"), 10, 16, 10)
.await
.expect("grep");
assert!(result.truncated);
assert_eq!(result.matches.len(), 2);
assert_eq!(result.matches[0].line, 2);
assert_eq!(result.matches[1].line, 1);
let single = grep_git_generation(&generation, "needle", Some("src/a.txt"), 1, 16, 10)
.await
.expect("grep one file");
assert!(!single.truncated);
assert_eq!(single.matches.len(), 1);
assert_eq!(single.matches[0].path, "src/a.txt");
assert_eq!(single.matches[0].line, 2);
}
#[tokio::test]
async fn patch_application_is_atomic_conflict_checked_and_idempotent() {
let fs = crate::Fs::memory();
let workspace = fs.create_workspace("git-patch").await.expect("workspace");
let mut transaction = workspace
.begin_transaction(IdempotencyKey::from_bytes([0x33; 16]))
.await
.expect("transaction");
transaction
.write_text("/a.txt", "one\nold\n")
.await
.expect("fixture");
transaction
.write_text("/gone.txt", "gone")
.await
.expect("no-newline fixture");
transaction.commit().await.expect("commit fixture");
let patch = b"--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n one\n-old\n+new\n";
let operation = IdempotencyKey::from_bytes([0x34; 16]);
let TransactionCommit::Committed(generation) =
apply_git_patch(&workspace, patch, operation)
.await
.expect("apply")
else {
panic!("patch did not commit");
};
assert_eq!(
generation
.read("/a.txt", 1_024)
.await
.expect("patched file")
.as_ref(),
b"one\nnew\n"
);
assert!(matches!(
apply_git_patch(&workspace, patch, operation)
.await
.expect("retry"),
TransactionCommit::AlreadyCommitted(_)
));
let conflicting = b"--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n one\n-old\n+other\n";
assert!(matches!(
apply_git_patch(
&workspace,
conflicting,
IdempotencyKey::from_bytes([0x35; 16])
)
.await,
Err(GitPatchError::Conflict { .. })
));
let add_and_delete = b"--- /dev/null\n+++ b/nested/new.txt\n@@ -0,0 +1 @@\n+made\n--- a/gone.txt\n+++ /dev/null\n@@ -1 +0,0 @@\n-gone\n\\ No newline at end of file\n";
let TransactionCommit::Committed(generation) = apply_git_patch(
&workspace,
add_and_delete,
IdempotencyKey::from_bytes([0x36; 16]),
)
.await
.expect("add and delete") else {
panic!("add/delete patch did not commit");
};
assert_eq!(
generation
.read("/nested/new.txt", 1_024)
.await
.expect("created file")
.as_ref(),
b"made\n"
);
assert!(generation.stat("/gone.txt").await.is_err());
assert!(matches!(
parse_unified_patch(b"--- C:/outside\n+++ C:/outside\n@@ -0,0 +1 @@\n+x\n"),
Err(GitPatchError::Invalid(_))
));
}
#[tokio::test]
async fn bisect_uses_durable_first_parent_generation_checkouts() {
let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
let mut commits = Vec::new();
let mut expected = None;
for byte in 1..=5 {
let GitCommandOutput::Committed(commit) = repository
.record_commit(
expected,
generation(byte),
BTreeSet::from(["tracked.txt".to_owned()]),
format!("commit {byte}"),
"agent".to_owned(),
i64::from(byte),
)
.await
.expect("record commit")
else {
panic!("expected recorded commit");
};
expected = Some(commit.id);
commits.push(commit.id);
}
let GitCommandOutput::Bisect(started) = repository
.run(
GitCommand::Bisect {
arguments: vec!["start".to_owned(), commits[4].to_hex(), commits[0].to_hex()],
},
generation(5),
&TestExecutor::returning(GitFilesystemResult::Applied {
tree: Some(tree(3)),
tracked_paths: None,
}),
)
.await
.expect("start bisect")
else {
panic!("expected bisect state");
};
assert_eq!(started.current, Some(commits[2]));
assert_eq!(started.remaining, 3);
let GitCommandOutput::Bisect(narrowed) = repository
.run(
GitCommand::Bisect {
arguments: vec!["good".to_owned()],
},
generation(3),
&TestExecutor::returning(GitFilesystemResult::Applied {
tree: Some(tree(4)),
tracked_paths: None,
}),
)
.await
.expect("mark good")
else {
panic!("expected narrowed bisect state");
};
assert_eq!(narrowed.current, Some(commits[3]));
let GitCommandOutput::Bisect(done) = repository
.run(
GitCommand::Bisect {
arguments: vec!["bad".to_owned()],
},
generation(4),
&TestExecutor::returning(GitFilesystemResult::Applied {
tree: Some(tree(4)),
tracked_paths: None,
}),
)
.await
.expect("mark bad")
else {
panic!("expected completed bisect state");
};
assert_eq!(done.first_bad, Some(commits[3]));
assert_eq!(done.remaining, 0);
let GitCommandOutput::Bisect(reset) = repository
.run(
GitCommand::Bisect {
arguments: vec!["reset".to_owned()],
},
generation(4),
&TestExecutor::returning(GitFilesystemResult::Applied {
tree: Some(tree(5)),
tracked_paths: None,
}),
)
.await
.expect("reset bisect")
else {
panic!("expected reset bisect state");
};
assert!(!reset.active);
}
#[tokio::test]
async fn distributed_publication_records_one_idempotent_merge_commit() {
let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
let GitCommandOutput::Committed(initial) = repository
.record_commit(
None,
generation(1),
BTreeSet::from(["tracked.txt".to_owned()]),
"initial",
"parent",
1,
)
.await
.expect("initial commit")
else {
panic!("expected initial commit");
};
let source = WorkspaceId::derive(
[0x51; 16],
&WorkspaceName::new("publication-source").expect("workspace name"),
);
repository
.register_branch_workspace("agents/child", source, Some(initial.id), false)
.await
.expect("source branch");
assert!(matches!(
repository
.record_publication(GitPublicationRecord {
tree: tree(1),
workspace_tree: GitTreeRef::exact(source, generation(1)),
source_head: None,
tracked_paths: BTreeSet::new(),
capture_proof: None,
message: "foreign no-op".to_owned(),
author: "parent".to_owned(),
authored_at_seconds: 2,
})
.await,
Err(GitCompatError::WorkspaceMismatch)
));
let tracked = BTreeSet::from(["tracked.txt".to_owned(), "new.txt".to_owned()]);
let GitCommandOutput::Committed(merged) = repository
.record_publication(GitPublicationRecord {
tree: tree(2),
workspace_tree: tree(3),
source_head: Some(initial.id),
tracked_paths: tracked.clone(),
capture_proof: None,
message: "Merge agents/child".to_owned(),
author: "parent".to_owned(),
authored_at_seconds: 2,
})
.await
.expect("publication commit")
else {
panic!("expected publication commit");
};
assert_eq!(merged.parents, vec![initial.id]);
assert_eq!(merged.tracked_paths, tracked);
assert_eq!(
repository.tracked_paths().await.expect("tracked paths"),
tracked
);
assert_eq!(
repository
.record_publication(GitPublicationRecord {
tree: tree(2),
workspace_tree: tree(3),
source_head: Some(initial.id),
tracked_paths: BTreeSet::new(),
capture_proof: None,
message: "retry".to_owned(),
author: "parent".to_owned(),
authored_at_seconds: 99,
})
.await
.expect("exact retry"),
GitCommandOutput::NoOp
);
}
#[tokio::test]
async fn cached_diff_is_head_to_workspace_and_add_is_noop() {
let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
let GitCommandOutput::Prepared {
transition,
action: GitFilesystemAction::CaptureCommit { .. },
} = repository
.execute(
GitCommand::Commit {
message: "initial".to_owned(),
author: "agent".to_owned(),
authored_at_seconds: 10,
},
generation(1),
)
.await
.expect("capture commit")
else {
panic!("expected commit capture");
};
repository
.complete_transition_result(
transition,
&GitFilesystemResult::Captured {
tree: tree(1),
tracked_paths: BTreeSet::new(),
proof: None,
},
)
.await
.expect("record commit");
assert_eq!(
repository
.execute(
GitCommand::Add {
paths: vec![".".to_owned()],
},
generation(2),
)
.await
.expect("add"),
GitCommandOutput::NoOp
);
assert!(matches!(
repository
.execute(GitCommand::Diff { cached: true }, generation(2))
.await
.expect("diff"),
GitCommandOutput::Prepared {
action: GitFilesystemAction::Diff {
from: Some(from),
to,
tracked_paths,
},
..
} if from == tree(1)
&& to == tree(2)
&& tracked_paths.is_empty()
));
}
#[tokio::test]
async fn every_branch_requests_and_registers_a_distinct_workspace() {
let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
let GitCommandOutput::Prepared {
transition,
action:
GitFilesystemAction::ForkBranch {
source_tree,
head,
switch,
..
},
} = repository
.execute(
GitCommand::Switch {
branch: "feature".to_owned(),
create: true,
},
generation(4),
)
.await
.expect("fork request")
else {
panic!("expected a core workspace fork request");
};
assert_eq!(source_tree, tree(4));
assert!(head.is_none());
assert!(switch);
let child = WorkspaceId::derive(
[9; 16],
&WorkspaceName::new("feature").expect("valid workspace name"),
);
repository
.complete_transition_result(
transition,
&GitFilesystemResult::Forked {
workspace_id: child,
},
)
.await
.expect("register branch workspace");
let GitCommandOutput::Status(status) = repository
.execute(GitCommand::Status, GitTreeRef::exact(child, generation(4)))
.await
.expect("feature status")
else {
panic!("expected status");
};
assert_eq!(status.branch, "feature");
}
#[tokio::test]
async fn argv_covers_local_history_and_rejects_transport() {
let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
assert!(matches!(
repository
.execute_argv(
&["reset".to_owned(), "--hard".to_owned(), "HEAD".to_owned()],
generation(1),
"agent",
10,
)
.await,
Err(GitCompatError::UnbornHead)
));
assert!(matches!(
repository
.execute_argv(&["clean".to_owned()], generation(1), "agent", 10,)
.await,
Err(GitCompatError::InvalidCommand(message)) if message.contains("requires -f")
));
assert!(matches!(
repository
.execute_argv(
&["clean".to_owned(), "--dry-run".to_owned()],
generation(1),
"agent",
10,
)
.await,
Ok(GitCommandOutput::Prepared {
action: GitFilesystemAction::Clean { dry_run: true, .. },
..
})
));
assert!(matches!(
repository
.execute_argv(&["push".to_owned()], generation(1), "agent", 10,)
.await,
Err(GitCompatError::Unsupported { .. })
));
}
#[test]
fn argv_parser_has_an_explicit_command_conformance_matrix() {
let cases = [
((&["status", "--short"] as &[&str]), GitCommand::Status),
(
(&["diff", "--staged"] as &[&str]),
GitCommand::Diff { cached: true },
),
(
(&["log", "--max-count=7"] as &[&str]),
GitCommand::Log { maximum: 7 },
),
(
(&["show", "HEAD"] as &[&str]),
GitCommand::Show {
object: Some(GitObjectName("HEAD".to_owned())),
},
),
(
(&["add", "--all"] as &[&str]),
GitCommand::Add { paths: Vec::new() },
),
(
(&["commit", "--message=ready", "--author=agent"] as &[&str]),
GitCommand::Commit {
message: "ready".to_owned(),
author: "agent".to_owned(),
authored_at_seconds: 10,
},
),
(
(&["branch", "topic"] as &[&str]),
GitCommand::Branch {
create: Some("topic".to_owned()),
},
),
(
(&["switch", "-c", "topic"] as &[&str]),
GitCommand::Switch {
branch: "topic".to_owned(),
create: true,
},
),
(
(&["restore", "--source=HEAD", "--", "file"] as &[&str]),
GitCommand::Restore {
source: Some(GitObjectName("HEAD".to_owned())),
paths: vec!["file".to_owned()],
},
),
(
(&["reset", "--hard", "HEAD"] as &[&str]),
GitCommand::Reset {
target: GitObjectName("HEAD".to_owned()),
mode: GitResetMode::Hard,
},
),
(
(&["merge", "topic"] as &[&str]),
GitCommand::Merge {
branch: "topic".to_owned(),
},
),
(
(&["rebase", "main"] as &[&str]),
GitCommand::Rebase {
branch: "main".to_owned(),
},
),
((&["stash", "push"] as &[&str]), GitCommand::StashPush),
((&["stash", "pop"] as &[&str]), GitCommand::StashPop),
(
(&["cherry-pick", "HEAD"] as &[&str]),
GitCommand::CherryPick {
object: GitObjectName("HEAD".to_owned()),
},
),
(
(&["revert", "HEAD"] as &[&str]),
GitCommand::Revert {
object: GitObjectName("HEAD".to_owned()),
},
),
(
(&["tag", "v1", "HEAD"] as &[&str]),
GitCommand::Tag {
name: Some("v1".to_owned()),
target: Some(GitObjectName("HEAD".to_owned())),
delete: false,
},
),
(
(&["blame", "file"] as &[&str]),
GitCommand::Blame {
path: "file".to_owned(),
},
),
(
(&["grep", "needle", "src"] as &[&str]),
GitCommand::Grep {
pattern: "needle".to_owned(),
path: Some("src".to_owned()),
},
),
(
(&["clean", "-n"] as &[&str]),
GitCommand::Clean { dry_run: true },
),
(
(&["archive", "HEAD"] as &[&str]),
GitCommand::Archive {
object: Some(GitObjectName("HEAD".to_owned())),
},
),
(
(&["bisect", "start", "bad", "good"] as &[&str]),
GitCommand::Bisect {
arguments: vec!["start".to_owned(), "bad".to_owned(), "good".to_owned()],
},
),
(
(&["checkout", "topic"] as &[&str]),
GitCommand::Switch {
branch: "topic".to_owned(),
create: false,
},
),
(
(&["checkout", "-b", "topic"] as &[&str]),
GitCommand::Switch {
branch: "topic".to_owned(),
create: true,
},
),
(
(&["checkout", "HEAD", "--", "src/lib.rs"] as &[&str]),
GitCommand::Restore {
source: Some(GitObjectName("HEAD".to_owned())),
paths: vec!["src/lib.rs".to_owned()],
},
),
(
(&["rev-parse", "--abbrev-ref", "HEAD"] as &[&str]),
GitCommand::RevParse {
argument: "--abbrev-ref HEAD".to_owned(),
},
),
(
(&["symbolic-ref", "--short", "HEAD"] as &[&str]),
GitCommand::SymbolicRef { short: true },
),
(
(&["merge-base", "HEAD", "topic"] as &[&str]),
GitCommand::MergeBase {
left: GitObjectName("HEAD".to_owned()),
right: GitObjectName("topic".to_owned()),
},
),
((&["ls-files"] as &[&str]), GitCommand::LsFiles),
(
(&["check-ignore", "--", "target/file"] as &[&str]),
GitCommand::CheckIgnore {
paths: vec!["target/file".to_owned()],
},
),
];
for (argv, expected) in cases {
let argv = argv
.iter()
.map(|value| (*value).to_owned())
.collect::<Vec<_>>();
assert_eq!(
parse_argv::<MemoryGitCompatStoreError>(&argv, "default", 10)
.expect("supported argv"),
expected,
"argv: {argv:?}"
);
}
assert_eq!(
parse_argv::<MemoryGitCompatStoreError>(
&["add".to_owned(), "--".to_owned(), "--all".to_owned()],
"default",
10,
)
.expect("literal option-looking path"),
GitCommand::Add {
paths: vec!["--all".to_owned()],
}
);
}
#[test]
fn argv_rejects_semantically_unrepresentable_and_shell_composed_commands() {
for command in [
"clone",
"fetch",
"pull",
"push",
"remote",
"gc",
"repack",
"cat-file",
"hash-object",
"init",
"fsck",
"prune",
"pack-objects",
"index-pack",
"receive-pack",
"upload-pack",
"read-tree",
"write-tree",
"commit-tree",
"update-index",
"worktree",
"submodule",
"apply",
] {
let argv = vec![command.to_owned()];
assert!(
matches!(
parse_argv::<MemoryGitCompatStoreError>(&argv, "agent", 10),
Err(GitCompatError::Unsupported { .. })
),
"command should be explicitly unsupported: {command}"
);
}
for argv in [
vec!["status", "&&", "push"],
vec!["status", "|", "cat"],
vec!["status", ">result"],
vec!["status", "$(push)"],
] {
let argv = argv.into_iter().map(str::to_owned).collect::<Vec<_>>();
assert!(matches!(
parse_argv::<MemoryGitCompatStoreError>(&argv, "agent", 10),
Err(GitCompatError::Unsupported { reason, .. }) if reason.contains("shell composition")
));
}
for argv in [
vec!["status", "unexpected"],
vec!["diff", "--stat"],
vec!["reset", "HEAD", "path"],
vec!["merge", "topic", "other"],
vec!["restore", "--source", "HEAD"],
vec!["clean", "-d"],
vec!["clean", "-fd"],
vec!["clean", "-nd"],
] {
let argv = argv.into_iter().map(str::to_owned).collect::<Vec<_>>();
assert!(parse_argv::<MemoryGitCompatStoreError>(&argv, "agent", 10).is_err());
}
}
#[tokio::test]
async fn state_changing_filesystem_actions_are_prepared_then_completed() {
let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
let GitCommandOutput::Prepared {
transition,
action: GitFilesystemAction::CaptureCommit { .. },
} = repository
.execute(
GitCommand::Commit {
message: "initial".to_owned(),
author: "agent".to_owned(),
authored_at_seconds: 10,
},
generation(1),
)
.await
.expect("prepare commit")
else {
panic!("expected commit capture");
};
let GitCommandOutput::Committed(commit) = repository
.complete_transition_result(
transition,
&GitFilesystemResult::Captured {
tree: tree(1),
tracked_paths: BTreeSet::new(),
proof: None,
},
)
.await
.expect("record commit")
else {
panic!("expected commit");
};
repository
.register_branch_workspace("feature", workspace(), Some(commit.id), false)
.await
.expect("register branch");
let GitCommandOutput::Prepared { transition, .. } = repository
.execute(
GitCommand::Switch {
branch: "feature".to_owned(),
create: false,
},
generation(2),
)
.await
.expect("prepare switch")
else {
panic!("expected prepared switch");
};
assert!(matches!(
repository
.execute(GitCommand::Status, generation(2))
.await,
Err(GitCompatError::TransitionPending(id)) if id == transition
));
repository
.complete_transition(transition, None)
.await
.expect("complete switch");
let GitCommandOutput::Status(status) = repository
.execute(GitCommand::Status, generation(1))
.await
.expect("status")
else {
panic!("expected status");
};
assert_eq!(status.branch, "feature");
}
#[test]
fn ignore_policy_keeps_tracked_paths_and_honors_last_match() {
let policy = GitIgnorePolicy::parse("target/\n*.log\n!important.log\n");
assert!(!policy.eligible("target", true, false));
assert!(!policy.eligible("target/cache.bin", false, false));
assert!(!policy.eligible("nested/target/cache.bin", false, false));
assert!(!policy.eligible("nested/debug.log", false, false));
assert!(policy.eligible("nested/important.log", false, false));
assert!(policy.eligible("target/cache", false, true));
}
#[test]
fn portable_git_names_decode_canonical_windows_names() {
let bytes = "source-λ.rs"
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect();
let name = crate::kernel::LogicalName::new(
crate::kernel::NameEncoding::WindowsUtf16Le,
bytes,
255,
)
.expect("valid Windows name");
assert_eq!(name.unicode_text().as_deref(), Some("source-λ.rs"));
}
}