Skip to main content

acyclic_fs/
git_compat.rs

1//! Git-shaped compatibility state over distributed filesystem generations.
2//!
3//! This module deliberately implements Git's familiar control vocabulary, not
4//! its object database or wire protocols. Compatibility commits point at exact
5//! authenticated filesystem generations. Commands that need to mutate or
6//! inspect the filesystem return a typed action so the same state machine can
7//! drive embedded, hosted, mounted, and language-bound executors.
8
9use crate::kernel::{FileKind, NamespacePath};
10use crate::model::{CheckoutMode, GenerationSelector, VolumeLimits};
11use crate::storage::ByteRange;
12use crate::workspace::customer_path;
13use crate::{
14    AsyncAuthorityStore, AsyncObjectStore, CancellationToken, Generation, GenerationId,
15    IdempotencyKey, OperationId, ResolvedFile, ResolvedFileRangeReadRequest, TransactionCommit,
16    WorkBudget, Workspace, WorkspaceError, WorkspaceId,
17};
18use serde::{Deserialize, Serialize};
19use std::collections::{BTreeMap, BTreeSet};
20use std::future::Future;
21use std::sync::Mutex;
22use thiserror::Error;
23
24const STATE_VERSION: u32 = 9;
25const COMMIT_DOMAIN: &[u8] = b"acyclic-fs-git-compat-commit-v1\0";
26const ACTION_DOMAIN: &[u8] = b"acyclic-fs-git-compat-action-v1\0";
27const MAXIMUM_CAS_ATTEMPTS: u8 = 32;
28const GREP_READ_CONCURRENCY: usize = 32;
29const MAXIMUM_PATCH_FILE_BYTES: u64 = 64 * 1024 * 1024;
30
31async fn resolved_git_regular_files<A: AsyncAuthorityStore, O: AsyncObjectStore>(
32    reader: &crate::PinnedReader<A, O>,
33    root_display: String,
34    root: NamespacePath,
35    limits: VolumeLimits,
36    maximum_entries: usize,
37    cancellation: &CancellationToken,
38) -> Result<(Vec<(String, ResolvedFile<A, O>)>, bool), WorkspaceError> {
39    let mut pending = vec![(root_display, root)];
40    let mut regular = Vec::new();
41    let mut visited = 0_usize;
42    let mut truncated = false;
43    'walk: while let Some((directory_display, directory)) = pending.pop() {
44        let mut after = None;
45        loop {
46            let page = reader
47                .resolve_directory_page(
48                    &directory,
49                    after.as_ref(),
50                    1_024,
51                    WorkBudget::UNBOUNDED,
52                    cancellation,
53                )
54                .await
55                .map_err(WorkspaceError::engine)?
56                .value;
57            let has_more = page.has_more;
58            after = page.entries.last().map(|entry| entry.name.clone());
59            for entry in page.entries {
60                if visited >= maximum_entries {
61                    truncated = true;
62                    break 'walk;
63                }
64                visited = visited.saturating_add(1);
65                let name = entry
66                    .name
67                    .unicode_text()
68                    .ok_or_else(|| WorkspaceError::path("non-portable Git path"))?;
69                let path = if directory_display.is_empty() {
70                    name.to_string()
71                } else {
72                    format!("{directory_display}/{name}")
73                };
74                if entry.file.description().kind == FileKind::Directory {
75                    let mut components = directory.components().to_vec();
76                    components.push(entry.name);
77                    let child = NamespacePath::new(components, limits)
78                        .map_err(|error| WorkspaceError::path(error.to_string()))?;
79                    pending.push((path, child));
80                } else if entry.file.description().kind == FileKind::Regular {
81                    regular.push((path, entry.file));
82                }
83            }
84            if !has_more {
85                break;
86            }
87        }
88    }
89    regular.sort_by(|left, right| left.0.cmp(&right.0));
90    Ok((regular, truncated))
91}
92
93async fn resolved_git_grep_files<A: AsyncAuthorityStore, O: AsyncObjectStore>(
94    reader: &crate::PinnedReader<A, O>,
95    root_display: String,
96    root: NamespacePath,
97    limits: VolumeLimits,
98    maximum_entries: usize,
99    cancellation: &CancellationToken,
100) -> Result<(Vec<(String, ResolvedFile<A, O>)>, bool), WorkspaceError> {
101    if root_display.is_empty() {
102        return resolved_git_regular_files(
103            reader,
104            root_display,
105            root,
106            limits,
107            maximum_entries,
108            cancellation,
109        )
110        .await;
111    }
112    let mut roots = reader
113        .resolve_files(
114            std::slice::from_ref(&root),
115            WorkBudget::UNBOUNDED,
116            cancellation,
117        )
118        .await
119        .map_err(WorkspaceError::engine)?
120        .value;
121    let root_file = roots.pop().flatten().ok_or(WorkspaceError::NotFound)?;
122    match root_file.description().kind {
123        FileKind::Directory => {
124            resolved_git_regular_files(
125                reader,
126                root_display,
127                root,
128                limits,
129                maximum_entries,
130                cancellation,
131            )
132            .await
133        }
134        FileKind::Regular => Ok((vec![(root_display, root_file)], false)),
135        _ => Ok((Vec::new(), false)),
136    }
137}
138
139/// Stable BLAKE3 compatibility commit identity.
140#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
141pub struct GitCommitId([u8; 32]);
142
143impl GitCommitId {
144    /// Restores an exact compatibility identity.
145    #[must_use]
146    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
147        Self(bytes)
148    }
149
150    /// Returns canonical bytes.
151    #[must_use]
152    pub const fn into_bytes(self) -> [u8; 32] {
153        self.0
154    }
155
156    /// Lower-case hexadecimal spelling accepted by the argv façade.
157    #[must_use]
158    pub fn to_hex(self) -> String {
159        hex::encode(self.0)
160    }
161}
162
163impl Serialize for GitCommitId {
164    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
165        serializer.serialize_str(&self.to_hex())
166    }
167}
168
169impl<'de> Deserialize<'de> for GitCommitId {
170    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
171        let value = String::deserialize(deserializer)?;
172        let bytes = hex::decode(&value).map_err(serde::de::Error::custom)?;
173        let bytes: [u8; 32] = bytes.try_into().map_err(|bytes: Vec<u8>| {
174            serde::de::Error::custom(format!(
175                "Git compatibility commit ID has {} bytes instead of 32",
176                bytes.len()
177            ))
178        })?;
179        Ok(Self(bytes))
180    }
181}
182
183/// A filtered tree's exact SDK fork origin, verified when core capture creates
184/// it. Durable JSON is checked structurally on reload, not revalidated against
185/// the SDK lineage store; it is not a cryptographic attestation.
186#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
187pub struct GitCaptureProof {
188    fork_parent: GitTreeRef,
189    initial_generation: crate::GenerationId,
190    operation_id: OperationId,
191}
192
193impl GitCaptureProof {
194    fn structurally_valid(
195        &self,
196        tree: GitTreeRef,
197        workspace_tree: GitTreeRef,
198        previous_tree: Option<GitTreeRef>,
199    ) -> bool {
200        let GitTreeRef::Exact(tree) = tree else {
201            return false;
202        };
203        let GitTreeRef::Exact(parent) = self.fork_parent else {
204            return false;
205        };
206        tree.workspace_id != workspace_tree.workspace_id()
207            && tree.generation != self.initial_generation
208            && (self.fork_parent == workspace_tree || previous_tree == Some(self.fork_parent))
209            && parent.workspace_id != tree.workspace_id
210    }
211}
212
213/// One explicit Git-facing commit mapped to an SDK generation.
214#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
215pub struct GitCommit {
216    /// Content-addressed compatibility identity.
217    pub id: GitCommitId,
218    /// Git-visible committed tree. Lazy trees retain unresolved source state.
219    pub tree: GitTreeRef,
220    /// Complete live working tree captured by the commit. This may differ from
221    /// `tree` when newly ignored paths are omitted from compatibility history.
222    pub workspace_tree: GitTreeRef,
223    /// SDK-verified fork origin when the Git-visible tree is a filtered fork.
224    #[serde(default)]
225    pub capture_proof: Option<GitCaptureProof>,
226    /// Exact portable paths represented by this compatibility snapshot.
227    #[serde(default)]
228    pub tracked_paths: BTreeSet<String>,
229    /// Ordered compatibility parents.
230    pub parents: Vec<GitCommitId>,
231    /// Human or agent identity.
232    pub author: String,
233    /// Unix epoch timestamp in seconds.
234    pub authored_at_seconds: i64,
235    /// Commit message.
236    pub message: String,
237}
238
239impl GitCommit {
240    /// Constructs and hashes an explicit compatibility commit.
241    #[must_use]
242    pub fn new(
243        tree: GitTreeRef,
244        parents: Vec<GitCommitId>,
245        author: impl Into<String>,
246        authored_at_seconds: i64,
247        message: impl Into<String>,
248    ) -> Self {
249        Self::new_with_workspace_tree(tree, tree, parents, author, authored_at_seconds, message)
250    }
251
252    fn new_with_workspace_tree(
253        tree: GitTreeRef,
254        workspace_tree: GitTreeRef,
255        parents: Vec<GitCommitId>,
256        author: impl Into<String>,
257        authored_at_seconds: i64,
258        message: impl Into<String>,
259    ) -> Self {
260        Self::new_with_metadata(
261            tree,
262            workspace_tree,
263            BTreeSet::new(),
264            parents,
265            author,
266            authored_at_seconds,
267            message,
268        )
269    }
270
271    fn new_with_metadata(
272        tree: GitTreeRef,
273        workspace_tree: GitTreeRef,
274        tracked_paths: BTreeSet<String>,
275        parents: Vec<GitCommitId>,
276        author: impl Into<String>,
277        authored_at_seconds: i64,
278        message: impl Into<String>,
279    ) -> Self {
280        Self::new_with_capture_proof(
281            tree,
282            workspace_tree,
283            None,
284            tracked_paths,
285            parents,
286            author,
287            authored_at_seconds,
288            message,
289        )
290    }
291
292    #[allow(clippy::too_many_arguments)]
293    fn new_with_capture_proof(
294        tree: GitTreeRef,
295        workspace_tree: GitTreeRef,
296        capture_proof: Option<GitCaptureProof>,
297        tracked_paths: BTreeSet<String>,
298        parents: Vec<GitCommitId>,
299        author: impl Into<String>,
300        authored_at_seconds: i64,
301        message: impl Into<String>,
302    ) -> Self {
303        let author = author.into();
304        let message = message.into();
305        let mut hasher = blake3::Hasher::new();
306        hasher.update(COMMIT_DOMAIN);
307        hash_tree_ref(&mut hasher, tree);
308        hash_tree_ref(&mut hasher, workspace_tree);
309        if let Some(proof) = &capture_proof {
310            hasher.update(&[1]);
311            hash_tree_ref(&mut hasher, proof.fork_parent);
312            hasher.update(proof.initial_generation.digest().as_bytes());
313            hasher.update(&proof.operation_id.into_bytes());
314        }
315        hasher.update(
316            &u64::try_from(tracked_paths.len())
317                .unwrap_or(u64::MAX)
318                .to_le_bytes(),
319        );
320        for path in &tracked_paths {
321            hash_bytes(&mut hasher, path.as_bytes());
322        }
323        hasher.update(
324            &u64::try_from(parents.len())
325                .unwrap_or(u64::MAX)
326                .to_le_bytes(),
327        );
328        for parent in &parents {
329            hasher.update(&parent.0);
330        }
331        hash_bytes(&mut hasher, author.as_bytes());
332        hasher.update(&authored_at_seconds.to_le_bytes());
333        hash_bytes(&mut hasher, message.as_bytes());
334        Self {
335            id: GitCommitId(*hasher.finalize().as_bytes()),
336            tree,
337            workspace_tree,
338            capture_proof,
339            tracked_paths,
340            parents,
341            author,
342            authored_at_seconds,
343            message,
344        }
345    }
346}
347
348fn hash_tree_ref(hasher: &mut blake3::Hasher, tree: GitTreeRef) {
349    match tree {
350        GitTreeRef::Exact(reference) => {
351            hasher.update(&[0]);
352            hasher.update(&reference.workspace_id.into_bytes());
353            hasher.update(reference.generation.digest().as_bytes());
354        }
355        GitTreeRef::Lazy(reference) => {
356            hasher.update(&[1]);
357            hasher.update(&reference.id.into_bytes());
358        }
359    }
360}
361
362fn hash_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
363    hasher.update(&u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes());
364    hasher.update(bytes);
365}
366
367/// Compatibility branch backed by one SDK workspace.
368#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
369pub struct GitBranch {
370    /// Canonical branch name.
371    pub name: String,
372    /// SDK workspace owning the live working copy.
373    pub workspace_id: WorkspaceId,
374    /// Last explicit compatibility commit, or unborn.
375    pub head: Option<GitCommitId>,
376    /// Paths represented by this branch's latest explicit commit.
377    #[serde(default)]
378    pub tracked_paths: BTreeSet<String>,
379}
380
381/// Exact compatibility snapshot identity across SDK workspaces.
382#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
383pub struct GitGenerationRef {
384    /// Workspace that authenticates the generation.
385    pub workspace_id: WorkspaceId,
386    /// Immutable generation identity.
387    pub generation: GenerationId,
388}
389
390impl From<GitGenerationRef> for GitTreeRef {
391    fn from(value: GitGenerationRef) -> Self {
392        Self::Exact(value)
393    }
394}
395
396/// One Git-visible tree, either fully authored or lazily source-backed.
397#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
398#[serde(tag = "kind", rename_all = "kebab-case")]
399pub enum GitTreeRef {
400    /// Exact authenticated authored generation.
401    Exact(GitGenerationRef),
402    /// Constant-size logical snapshot retaining unresolved source semantics.
403    Lazy(crate::LazySnapshotRef),
404}
405
406impl GitTreeRef {
407    /// Constructs an exact authored tree reference.
408    #[must_use]
409    pub const fn exact(workspace_id: WorkspaceId, generation: GenerationId) -> Self {
410        Self::Exact(GitGenerationRef {
411            workspace_id,
412            generation,
413        })
414    }
415    /// Workspace owning the authored component.
416    #[must_use]
417    pub const fn workspace_id(self) -> WorkspaceId {
418        match self {
419            Self::Exact(reference) => reference.workspace_id,
420            Self::Lazy(reference) => reference.workspace_id,
421        }
422    }
423
424    /// Authenticated authored generation component.
425    #[must_use]
426    pub const fn authored_generation(self) -> GenerationId {
427        match self {
428            Self::Exact(reference) => reference.generation,
429            Self::Lazy(reference) => reference.authored_generation,
430        }
431    }
432}
433
434/// Ergonomic input accepted by the Git façade for a live workspace tree.
435pub trait IntoGitTreeRef {
436    /// Resolves the input using the repository workspace for exact generations.
437    fn into_git_tree_ref(self, repository_workspace: WorkspaceId) -> GitTreeRef;
438}
439
440impl IntoGitTreeRef for GitTreeRef {
441    fn into_git_tree_ref(self, _repository_workspace: WorkspaceId) -> GitTreeRef {
442        self
443    }
444}
445
446impl IntoGitTreeRef for GenerationId {
447    fn into_git_tree_ref(self, repository_workspace: WorkspaceId) -> GitTreeRef {
448        GitTreeRef::exact(repository_workspace, self)
449    }
450}
451
452/// Versioned private compatibility state.
453#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
454pub struct GitCompatState {
455    /// Serialization contract version.
456    pub version: u32,
457    /// Monotonic optimistic-concurrency revision.
458    pub revision: u64,
459    /// Currently selected compatibility branch.
460    pub current_branch: String,
461    /// Branch catalog.
462    pub branches: BTreeMap<String, GitBranch>,
463    /// Explicit commit records.
464    pub commits: BTreeMap<GitCommitId, GitCommit>,
465    /// Cross-repository parents introduced by direct child publication.
466    /// These identities are explicit graph boundaries, never silently missing
467    /// local records.
468    #[serde(default)]
469    pub external_parents: BTreeSet<GitCommitId>,
470    /// Lightweight tags.
471    pub tags: BTreeMap<String, GitCommitId>,
472    /// Most-recent-first prior branch heads.
473    pub reflog: Vec<Option<GitCommitId>>,
474    /// Stashed exact generations, newest last.
475    pub stash: Vec<GitTreeRef>,
476    /// Version-five migration source. New state keeps tracking information on
477    /// each branch and never serializes this empty compatibility field.
478    #[serde(
479        default,
480        rename = "tracked_paths",
481        skip_serializing_if = "BTreeSet::is_empty"
482    )]
483    pub legacy_tracked_paths: BTreeSet<String>,
484    /// Durable sequencer transition prepared before filesystem side effects.
485    #[serde(default)]
486    pub pending: Option<GitPendingTransition>,
487    /// Active first-parent bisection, if any.
488    #[serde(default)]
489    pub bisect: Option<GitBisectState>,
490}
491
492impl GitCompatState {
493    /// Creates an unborn branch for one SDK workspace.
494    #[must_use]
495    pub fn new(branch: impl Into<String>, workspace_id: WorkspaceId) -> Self {
496        let branch = branch.into();
497        let mut branches = BTreeMap::new();
498        branches.insert(
499            branch.clone(),
500            GitBranch {
501                name: branch.clone(),
502                workspace_id,
503                head: None,
504                tracked_paths: BTreeSet::new(),
505            },
506        );
507        Self {
508            version: STATE_VERSION,
509            revision: 0,
510            current_branch: branch,
511            branches,
512            commits: BTreeMap::new(),
513            external_parents: BTreeSet::new(),
514            tags: BTreeMap::new(),
515            reflog: Vec::new(),
516            stash: Vec::new(),
517            legacy_tracked_paths: BTreeSet::new(),
518            pending: None,
519            bisect: None,
520        }
521    }
522
523    fn current(&self) -> Result<&GitBranch, GitCompatStateError> {
524        self.branches
525            .get(&self.current_branch)
526            .ok_or(GitCompatStateError::Invalid)
527    }
528
529    fn current_mut(&mut self) -> Result<&mut GitBranch, GitCompatStateError> {
530        self.branches
531            .get_mut(&self.current_branch)
532            .ok_or(GitCompatStateError::Invalid)
533    }
534}
535
536/// Stable identity for one prepared compatibility filesystem transition.
537#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
538#[serde(transparent)]
539pub struct GitTransitionId(OperationId);
540
541impl GitTransitionId {
542    /// Creates a fresh transition identity.
543    #[must_use]
544    pub fn new() -> Self {
545        Self(OperationId::new())
546    }
547
548    /// Restores a transition identity from canonical bytes.
549    #[must_use]
550    pub const fn from_bytes(bytes: [u8; 16]) -> Self {
551        Self(OperationId::from_bytes(bytes))
552    }
553
554    /// Returns canonical bytes.
555    #[must_use]
556    pub const fn into_bytes(self) -> [u8; 16] {
557        self.0.into_bytes()
558    }
559
560    /// Returns the underlying operation identity.
561    #[must_use]
562    pub const fn operation_id(self) -> OperationId {
563        self.0
564    }
565}
566
567impl Default for GitTransitionId {
568    fn default() -> Self {
569        Self::new()
570    }
571}
572
573/// State mutation committed only after its filesystem action succeeds.
574#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
575#[non_exhaustive]
576pub enum GitPendingMutation {
577    /// Clear the durable action intent without changing compatibility state.
578    NoOp,
579    /// Record a commit only after its filtered filesystem snapshot is durable.
580    CaptureCommit {
581        /// Complete live tree that was captured.
582        workspace_tree: GitTreeRef,
583        /// Commit message retained across executor recovery.
584        message: String,
585        /// Commit author retained across executor recovery.
586        author: String,
587        /// Signed Unix epoch timestamp retained across executor recovery.
588        authored_at_seconds: i64,
589        /// Exact compatibility head that must still be current.
590        expected_head: Option<GitCommitId>,
591    },
592    /// Register a compatibility branch only after its SDK workspace exists.
593    ForkBranch {
594        /// New branch name.
595        branch: String,
596        /// Compatibility head inherited by the branch.
597        head: Option<GitCommitId>,
598        /// Whether the new branch becomes current.
599        switch: bool,
600    },
601    /// Select a branch after its generation is installed.
602    Switch {
603        /// Branch selected after restoration.
604        branch: String,
605    },
606    /// Move the current branch head after a hard restore.
607    Reset {
608        /// Commit installed after restoration.
609        head: GitCommitId,
610    },
611    /// Record the dirty generation after restoring compatibility HEAD.
612    StashPush {
613        /// Dirty tree retained by the stash.
614        tree: GitTreeRef,
615    },
616    /// Remove the exact top stash after it is restored.
617    StashPop {
618        /// Exact top stash restored before removal.
619        tree: GitTreeRef,
620    },
621    /// Record a merge or rebase result generation.
622    Join {
623        /// Source compatibility head captured while preparing.
624        source_head: Option<GitCommitId>,
625        /// Source branch used in the generated commit message.
626        source_branch: String,
627        /// Whether resulting ancestry is rebased rather than merged.
628        rebase: bool,
629    },
630    /// Record a cherry-pick or revert result generation.
631    ApplyCommit {
632        /// Commit whose delta is applied.
633        commit: GitCommitId,
634        /// Whether the delta is inverted.
635        reverse: bool,
636    },
637    /// Install one bisection checkout and then publish its durable session state.
638    Bisect {
639        /// Replacement session, or `None` when resetting.
640        state: Box<Option<GitBisectState>>,
641        /// Machine-readable result returned after the checkout succeeds.
642        result: GitBisectResult,
643    },
644}
645
646/// Durable first-parent bisection state.
647#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
648pub struct GitBisectState {
649    /// Branch that was active before bisection began.
650    pub original_branch: String,
651    /// Commit restored by `bisect reset`.
652    pub original_head: GitCommitId,
653    /// Known good ancestor, when supplied.
654    pub good: Option<GitCommitId>,
655    /// Known bad descendant.
656    pub bad: GitCommitId,
657    /// Commit currently installed for testing.
658    pub current: Option<GitCommitId>,
659    /// Commits excluded by `bisect skip`.
660    pub skipped: BTreeSet<GitCommitId>,
661}
662
663/// Stable machine-readable bisection result.
664#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
665pub struct GitBisectResult {
666    /// Whether a session remains active.
667    pub active: bool,
668    /// Known good ancestor.
669    pub good: Option<GitCommitId>,
670    /// Known bad descendant.
671    pub bad: Option<GitCommitId>,
672    /// Commit installed for the caller to test.
673    pub current: Option<GitCommitId>,
674    /// Number of unclassified candidates remaining.
675    pub remaining: u32,
676    /// First bad commit once no candidates remain.
677    pub first_bad: Option<GitCommitId>,
678}
679
680/// Durable prepared action and its deferred compatibility-state mutation.
681#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
682pub struct GitPendingTransition {
683    /// Stable retry identity.
684    pub id: GitTransitionId,
685    /// Filesystem action to execute idempotently.
686    pub action: GitFilesystemAction,
687    /// Mutation finalized only after successful action execution.
688    pub mutation: GitPendingMutation,
689}
690
691/// Revision or branch-like object name.
692#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
693pub struct GitObjectName(pub String);
694
695/// Reset behavior supported by the compatibility layer.
696#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
697pub enum GitResetMode {
698    /// Move compatibility HEAD without changing the workspace.
699    Soft,
700    /// Same as soft because there is no separate index.
701    Mixed,
702    /// Move HEAD and restore the workspace generation.
703    Hard,
704}
705
706/// Parsed Git-shaped command.
707#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
708#[non_exhaustive]
709pub enum GitCommand {
710    /// Compare compatibility HEAD with the live workspace.
711    Status,
712    /// Diff HEAD-to-workspace; cached has identical semantics.
713    Diff {
714        /// Whether the caller used `--cached`; behavior is identical without an index.
715        cached: bool,
716    },
717    /// Walk explicit compatibility history.
718    Log {
719        /// Maximum first-parent commits to return.
720        maximum: u32,
721    },
722    /// Inspect one compatibility commit.
723    Show {
724        /// Object to show, defaulting to `HEAD`.
725        object: Option<GitObjectName>,
726    },
727    /// Accepted no-op because changes are always staged.
728    Add {
729        /// Accepted pathspecs; staging remains automatic.
730        paths: Vec<String>,
731    },
732    /// Capture the complete eligible workspace state.
733    Commit {
734        /// Commit message.
735        message: String,
736        /// Author identity.
737        author: String,
738        /// Signed Unix epoch time.
739        authored_at_seconds: i64,
740    },
741    /// List or create a branch.
742    Branch {
743        /// Optional branch to create before listing.
744        create: Option<String>,
745    },
746    /// Select a branch, optionally creating it first.
747    Switch {
748        /// Destination branch.
749        branch: String,
750        /// Whether to create a missing destination.
751        create: bool,
752    },
753    /// Restore paths from an object.
754    Restore {
755        /// Source object, defaulting to `HEAD`.
756        source: Option<GitObjectName>,
757        /// Portable paths to restore.
758        paths: Vec<String>,
759    },
760    /// Move HEAD and optionally the workspace.
761    Reset {
762        /// Commit-like target.
763        target: GitObjectName,
764        /// Workspace restoration policy.
765        mode: GitResetMode,
766    },
767    /// Join another compatibility branch.
768    Merge {
769        /// Source branch.
770        branch: String,
771    },
772    /// Continue the one durable conflicted merge after ordinary workspace edits.
773    MergeContinue,
774    /// Restore the pre-merge working tree and clear the durable merge intent.
775    MergeAbort,
776    /// Rebase onto another compatibility branch.
777    Rebase {
778        /// New base branch.
779        branch: String,
780    },
781    /// Stash the complete working generation.
782    StashPush,
783    /// Restore and remove the newest stash.
784    StashPop,
785    /// Apply one commit as a cherry-pick.
786    CherryPick {
787        /// Commit to apply.
788        object: GitObjectName,
789    },
790    /// Invert one explicit commit.
791    Revert {
792        /// Commit to invert.
793        object: GitObjectName,
794    },
795    /// List, create, or delete a lightweight tag.
796    Tag {
797        /// Optional tag name; omission lists tags.
798        name: Option<String>,
799        /// Target object, defaulting to `HEAD`.
800        target: Option<GitObjectName>,
801        /// Whether to delete instead of create.
802        delete: bool,
803    },
804    /// Attribute lines to compatibility commits.
805    Blame {
806        /// Portable path to attribute.
807        path: String,
808    },
809    /// Search one live or historical tree.
810    Grep {
811        /// Search pattern interpreted by the executor.
812        pattern: String,
813        /// Optional subtree path.
814        path: Option<String>,
815    },
816    /// Remove untracked, non-ignored paths.
817    Clean {
818        /// Whether to report candidates without mutation.
819        dry_run: bool,
820    },
821    /// Export one compatibility tree.
822    Archive {
823        /// Object to archive, defaulting to the workspace.
824        object: Option<GitObjectName>,
825    },
826    /// Apply a patch to the working workspace.
827    Apply {
828        /// Opaque patch bytes.
829        patch: Vec<u8>,
830    },
831    /// Advance a caller-directed bisect session.
832    Bisect {
833        /// Bisect subcommand and arguments.
834        arguments: Vec<String>,
835    },
836    /// Resolve one compatibility revision or a small read-only probe.
837    RevParse {
838        /// Probe or object name accepted by the compatibility subset.
839        argument: String,
840    },
841    /// Print the symbolic compatibility HEAD.
842    SymbolicRef {
843        /// Whether to omit the `refs/heads/` prefix.
844        short: bool,
845    },
846    /// Find the nearest common explicit compatibility commit.
847    MergeBase {
848        /// First commit-like object.
849        left: GitObjectName,
850        /// Second commit-like object.
851        right: GitObjectName,
852    },
853    /// List paths tracked by explicit compatibility history.
854    LsFiles,
855    /// Test paths against the live compatibility ignore policy.
856    CheckIgnore {
857        /// Portable paths to test.
858        paths: Vec<String>,
859    },
860}
861
862/// Filesystem work emitted by the compatibility state machine.
863#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
864#[non_exhaustive]
865pub enum GitFilesystemAction {
866    /// Capture a compatibility-eligible generation before recording a commit.
867    CaptureCommit {
868        /// Complete live workspace tree.
869        workspace_tree: GitTreeRef,
870        /// Previous filtered compatibility tree, if HEAD exists.
871        head_tree: Option<GitTreeRef>,
872        /// Previous complete live tree, if recorded by HEAD.
873        head_workspace_tree: Box<Option<GitTreeRef>>,
874        /// Paths already tracked and therefore eligible even if newly ignored.
875        tracked_paths: BTreeSet<String>,
876        /// Commit message retained until capture completes.
877        message: String,
878        /// Author retained until capture completes.
879        author: String,
880        /// Signed Unix epoch timestamp retained until capture completes.
881        authored_at_seconds: i64,
882        /// Exact HEAD that must still be current at completion.
883        expected_head: Option<GitCommitId>,
884    },
885    /// Fork a dedicated SDK workspace for a compatibility branch.
886    ForkBranch {
887        /// New branch name.
888        branch: String,
889        /// Tree from which to fork the branch workspace.
890        source_tree: GitTreeRef,
891        /// Compatibility head inherited by the new branch.
892        head: Option<GitCommitId>,
893        /// Whether the new branch should become current after registration.
894        switch: bool,
895    },
896    /// Select an existing branch's live SDK workspace.
897    SwitchWorkspace {
898        /// Workspace that owns the destination branch's working copy.
899        workspace_id: WorkspaceId,
900    },
901    /// Compute a semantic diff between exact generations.
902    Diff {
903        /// Explicit compatibility HEAD, or no baseline for an unborn branch.
904        from: Option<GitTreeRef>,
905        /// Live workspace generation.
906        to: GitTreeRef,
907        /// Already tracked paths remain visible even if later ignored.
908        tracked_paths: BTreeSet<String>,
909    },
910    /// Move the live workspace to an exact generation.
911    RestoreGeneration {
912        /// Tree to install.
913        tree: GitTreeRef,
914        /// Paths participating in a compatibility-history restore. `None`
915        /// requests an exact same-workspace head restoration (for stash).
916        paths: Option<BTreeSet<String>>,
917    },
918    /// Restore selected paths from an exact generation.
919    RestorePaths {
920        /// Source tree.
921        tree: GitTreeRef,
922        /// Portable paths to replace.
923        paths: Vec<String>,
924    },
925    /// Join a source workspace into the current workspace.
926    Join {
927        /// Exact target working tree captured before the join began.
928        target_tree: GitTreeRef,
929        /// Workspace owning the source branch.
930        source_workspace: WorkspaceId,
931        /// Whether to record rebase rather than merge ancestry.
932        rebase: bool,
933        /// Complete set of paths that may remain tracked after the join.
934        tracked_paths: BTreeSet<String>,
935    },
936    /// Apply one commit relative to its first parent.
937    ApplyCommit {
938        /// Explicit compatibility commit.
939        commit: GitCommitId,
940        /// Whether to apply its inverse.
941        reverse: bool,
942        /// Exact earlier tree, or the empty compatibility tree.
943        base: Option<GitTreeRef>,
944        /// Exact later tree, or the empty compatibility tree.
945        source: Option<GitTreeRef>,
946        /// Complete portable path set participating in the delta.
947        paths: BTreeSet<String>,
948        /// Complete set of paths that may remain tracked after application.
949        tracked_paths: BTreeSet<String>,
950    },
951    /// Attribute a path across explicit commit history.
952    Blame {
953        /// Portable path to attribute.
954        path: String,
955        /// Newest-first explicit history to inspect.
956        commits: Vec<GitCommit>,
957    },
958    /// Search the live tree.
959    Grep {
960        /// Search pattern.
961        pattern: String,
962        /// Optional subtree path.
963        path: Option<String>,
964        /// Exact live tree to search.
965        tree: GitTreeRef,
966    },
967    /// Discover and optionally remove untracked paths.
968    Clean {
969        /// Whether mutation is disabled.
970        dry_run: bool,
971        /// Exact live tree to inspect before any deletion.
972        tree: GitTreeRef,
973        /// Paths protected as tracked by the current branch.
974        tracked_paths: BTreeSet<String>,
975    },
976    /// Export one exact generation.
977    Archive {
978        /// Tree to export.
979        tree: GitTreeRef,
980    },
981    /// Parse and apply a patch.
982    ApplyPatch {
983        /// Opaque patch bytes.
984        patch: Vec<u8>,
985    },
986    /// Test paths against the ignore policy in one exact live tree.
987    CheckIgnore {
988        /// Paths to test without shell expansion.
989        paths: Vec<String>,
990        /// Exact live tree containing `.gitignore` files.
991        tree: GitTreeRef,
992    },
993}
994
995/// Typed result returned by a compatibility filesystem executor.
996#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
997#[non_exhaustive]
998#[allow(clippy::large_enum_variant)] // Keep the public result shape direct; this is not a hot path.
999pub enum GitFilesystemResult {
1000    /// An eligible compatibility snapshot was captured.
1001    Captured {
1002        /// Filtered compatibility tree.
1003        tree: GitTreeRef,
1004        /// Paths represented by the captured compatibility history.
1005        tracked_paths: BTreeSet<String>,
1006        /// Authenticated origin when `tree` belongs to a filtered SDK fork.
1007        proof: Option<GitCaptureProof>,
1008    },
1009    /// A dedicated compatibility branch workspace was created.
1010    Forked {
1011        /// Stable SDK workspace identity.
1012        workspace_id: WorkspaceId,
1013    },
1014    /// A mutating action completed, optionally producing a new generation.
1015    Applied {
1016        /// Resulting tree when the action changes workspace state.
1017        tree: Option<GitTreeRef>,
1018        /// Exact resulting tracked paths for generated compatibility commits.
1019        tracked_paths: Option<BTreeSet<String>>,
1020    },
1021    /// Stable executor-defined inspection data.
1022    Data {
1023        /// Result category such as `diff`, `grep`, or `archive`.
1024        kind: String,
1025        /// Versioned machine-readable result body.
1026        value: serde_json::Value,
1027    },
1028}
1029
1030impl GitFilesystemResult {
1031    fn resulting_tree(&self) -> Option<GitTreeRef> {
1032        match self {
1033            Self::Captured { tree, .. } => Some(*tree),
1034            Self::Applied { tree, .. } => *tree,
1035            Self::Forked { .. } | Self::Data { .. } => None,
1036        }
1037    }
1038}
1039
1040/// Whether a lazy Git working tree is known to differ from compatibility HEAD.
1041#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1042#[serde(rename_all = "kebab-case")]
1043pub enum GitDirtyState {
1044    /// Every represented path is known equal.
1045    Clean,
1046    /// At least one represented path is known different.
1047    Dirty,
1048    /// Unresolved source state prevents an exact answer without a scan.
1049    Unknown,
1050}
1051
1052/// Backend-neutral execution boundary for Git-shaped filesystem work.
1053///
1054/// Implementations receive a stable operation identity for every action. They
1055/// must apply mutating actions idempotently and return only typed results; the
1056/// compatibility repository retains all history and sequencer authority.
1057pub trait GitFilesystemExecutor: Send + Sync {
1058    /// Executor failure.
1059    type Error: std::error::Error + Send + Sync + 'static;
1060
1061    /// Confirms that the caller still owns any writer lease governing this command.
1062    fn validate(&self) -> impl Future<Output = Result<(), Self::Error>> + Send {
1063        async { Ok(()) }
1064    }
1065
1066    /// Executes or recovers one exact action.
1067    fn execute(
1068        &self,
1069        operation_id: OperationId,
1070        action: &GitFilesystemAction,
1071    ) -> impl Future<Output = Result<GitFilesystemResult, Self::Error>> + Send;
1072}
1073
1074/// Git-shaped status without an index.
1075#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1076pub struct GitStatus {
1077    /// Current compatibility branch.
1078    pub branch: String,
1079    /// Explicit compatibility HEAD.
1080    pub head: Option<GitCommitId>,
1081    /// Live workspace tree.
1082    pub workspace: GitTreeRef,
1083    /// Whether HEAD and the workspace differ.
1084    pub dirty: GitDirtyState,
1085    /// Always true: the compatibility layer stages all eligible changes.
1086    pub all_changes_staged: bool,
1087}
1088
1089/// Stable machine-readable command result.
1090#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1091#[non_exhaustive]
1092#[allow(clippy::large_enum_variant)] // CLI responses favor a direct typed shape over allocation.
1093pub enum GitCommandOutput {
1094    /// No state or filesystem change was required.
1095    NoOp,
1096    /// Status result.
1097    Status(GitStatus),
1098    /// Ordered commit records.
1099    Commits(Vec<GitCommit>),
1100    /// Branch catalog and active branch.
1101    Branches {
1102        /// Active branch name.
1103        current: String,
1104        /// Complete branch catalog.
1105        branches: Vec<GitBranch>,
1106    },
1107    /// Tag catalog.
1108    Tags(BTreeMap<String, GitCommitId>),
1109    /// New explicit commit.
1110    Committed(GitCommit),
1111    /// Durable first-parent bisection state.
1112    Bisect(GitBisectResult),
1113    /// Compatibility state advanced and filesystem work is required.
1114    Action(GitFilesystemAction),
1115    /// Filesystem work durably prepared before a state-changing transition.
1116    Prepared {
1117        /// Stable completion/abort identity.
1118        transition: GitTransitionId,
1119        /// Idempotent filesystem action to execute.
1120        action: GitFilesystemAction,
1121    },
1122    /// Completed filesystem inspection or mutation result.
1123    Filesystem(GitFilesystemResult),
1124    /// Stable line-oriented value used by read-only Git probes.
1125    Text(String),
1126    /// Stable ordered portable-path result.
1127    Paths(Vec<String>),
1128}
1129
1130/// Durable optimistic-concurrency adapter for private compatibility state.
1131pub trait GitCompatStore: Send + Sync {
1132    /// Adapter error.
1133    type Error: std::error::Error + Send + Sync + 'static;
1134
1135    /// Loads state for one repository/workspace identity.
1136    fn load(
1137        &self,
1138        workspace_id: WorkspaceId,
1139    ) -> impl Future<Output = Result<Option<GitCompatState>, Self::Error>> + Send;
1140
1141    /// Atomically replaces one expected revision. Revision zero creates state.
1142    fn compare_and_swap(
1143        &self,
1144        workspace_id: WorkspaceId,
1145        expected_revision: u64,
1146        replacement: GitCompatState,
1147    ) -> impl Future<Output = Result<bool, Self::Error>> + Send;
1148
1149    /// Atomically removes one exact compatibility-state revision.
1150    fn compare_and_delete(
1151        &self,
1152        workspace_id: WorkspaceId,
1153        expected_revision: u64,
1154    ) -> impl Future<Output = Result<bool, Self::Error>> + Send;
1155}
1156
1157#[derive(Debug, Error)]
1158enum GitCompatStateError {
1159    #[error("invalid compatibility state")]
1160    Invalid,
1161    #[error("unknown object")]
1162    Unknown(String),
1163    #[error("branch already exists")]
1164    BranchExists(String),
1165    #[error("HEAD is unborn")]
1166    UnbornHead,
1167    #[error("nothing to commit")]
1168    NothingToCommit,
1169    #[error("stash is empty")]
1170    EmptyStash,
1171    #[error("invalid bisect operation: {0}")]
1172    Bisect(String),
1173}
1174
1175/// Git compatibility failure.
1176#[derive(Debug, Error)]
1177pub enum GitCompatError<E: std::error::Error + 'static> {
1178    /// Durable adapter failed.
1179    #[error("Git compatibility store failed: {0}")]
1180    Store(E),
1181    /// Persisted state is incompatible or internally inconsistent.
1182    #[error("Git compatibility state is invalid")]
1183    InvalidState,
1184    /// The supplied live tree belongs to a workspace other than the selected branch.
1185    #[error("Git compatibility live tree does not belong to the selected branch workspace")]
1186    WorkspaceMismatch,
1187    /// Command arguments are invalid.
1188    #[error("invalid Git-compatible command: {0}")]
1189    InvalidCommand(String),
1190    /// Named branch, tag, or commit does not exist.
1191    #[error("unknown Git-compatible object '{0}'")]
1192    UnknownObject(String),
1193    /// Named branch already exists.
1194    #[error("Git-compatible branch '{0}' already exists")]
1195    BranchExists(String),
1196    /// There is no explicit commit to name.
1197    #[error("Git-compatible HEAD is unborn")]
1198    UnbornHead,
1199    /// Nothing differs from explicit compatibility HEAD.
1200    #[error("nothing to commit")]
1201    NothingToCommit,
1202    /// Transport or object-database command is intentionally unavailable.
1203    #[error("unsupported Git command '{command}': {reason}")]
1204    Unsupported {
1205        /// Requested command name.
1206        command: String,
1207        /// Stable explanation of the unsupported capability.
1208        reason: String,
1209    },
1210    /// Optimistic state contention exceeded the bounded retry policy.
1211    #[error("Git compatibility state remained contended")]
1212    Contended,
1213    /// Another sequencer transition must be completed or aborted first.
1214    #[error("Git compatibility transition {0:?} is still pending")]
1215    TransitionPending(GitTransitionId),
1216    /// Completion or abort named a stale transition.
1217    #[error("Git compatibility transition is stale")]
1218    StaleTransition,
1219    /// The completed filesystem action did not return its resulting generation.
1220    #[error("Git compatibility transition requires a resulting generation")]
1221    MissingResultGeneration,
1222}
1223
1224/// Failure from the composed compatibility state machine and action executor.
1225#[derive(Debug, Error)]
1226pub enum GitCompatRunError<S: std::error::Error + 'static, E: std::error::Error + 'static> {
1227    /// Compatibility state, parsing, or optimistic publication failed.
1228    #[error(transparent)]
1229    Compat(#[from] GitCompatError<S>),
1230    /// The filesystem executor failed; prepared state remains recoverable.
1231    #[error("Git compatibility filesystem action failed: {0}")]
1232    Executor(E),
1233    /// The executor returned a result that does not match the requested action.
1234    #[error("Git compatibility executor returned an invalid result")]
1235    InvalidResult,
1236    /// A typed action could not be encoded for its deterministic identity.
1237    #[error("Git compatibility action encoding failed: {0}")]
1238    Encoding(#[from] serde_json::Error),
1239}
1240
1241/// Repository façade over one SDK workspace and private compatibility state.
1242pub struct GitCompatRepository<S> {
1243    workspace_id: WorkspaceId,
1244    store: S,
1245}
1246
1247struct GitCommitRecord {
1248    expected_head: Option<GitCommitId>,
1249    tree: GitTreeRef,
1250    workspace_tree: GitTreeRef,
1251    tracked_paths: BTreeSet<String>,
1252    capture_proof: Option<GitCaptureProof>,
1253    message: String,
1254    author: String,
1255    authored_at_seconds: i64,
1256}
1257
1258/// Compatibility history record produced by one distributed publication.
1259#[derive(Clone, Debug, Eq, PartialEq)]
1260pub struct GitPublicationRecord {
1261    /// Filtered Git-visible tree.
1262    pub tree: GitTreeRef,
1263    /// Complete live workspace tree after publication.
1264    pub workspace_tree: GitTreeRef,
1265    /// Optional compatibility head from the published child.
1266    pub source_head: Option<GitCommitId>,
1267    /// Paths represented by the filtered compatibility tree.
1268    pub tracked_paths: BTreeSet<String>,
1269    /// SDK-verified origin for a filtered publication tree.
1270    pub capture_proof: Option<GitCaptureProof>,
1271    /// Merge commit message.
1272    pub message: String,
1273    /// Author identity.
1274    pub author: String,
1275    /// Unix epoch timestamp in seconds.
1276    pub authored_at_seconds: i64,
1277}
1278
1279impl<S> GitCompatRepository<S> {
1280    /// Creates a façade. State is initialized lazily on the first command.
1281    #[must_use]
1282    pub const fn new(workspace_id: WorkspaceId, store: S) -> Self {
1283        Self {
1284            workspace_id,
1285            store,
1286        }
1287    }
1288
1289    /// Borrows the state adapter.
1290    #[must_use]
1291    pub const fn store(&self) -> &S {
1292        &self.store
1293    }
1294}
1295
1296impl<S: GitCompatStore> GitCompatRepository<S> {
1297    /// Returns the paths tracked by the current compatibility branch.
1298    pub async fn tracked_paths(&self) -> Result<BTreeSet<String>, GitCompatError<S::Error>> {
1299        self.load()
1300            .await?
1301            .current()
1302            .map(|branch| branch.tracked_paths.clone())
1303            .map_err(|_| GitCompatError::InvalidState)
1304    }
1305
1306    /// Returns the latest explicit compatibility commit for the current branch.
1307    pub async fn head(&self) -> Result<Option<GitCommitId>, GitCompatError<S::Error>> {
1308        self.load()
1309            .await?
1310            .current()
1311            .map(|branch| branch.head)
1312            .map_err(|_| GitCompatError::InvalidState)
1313    }
1314
1315    /// Executes a command and all required filesystem work through one composed
1316    /// recovery-safe boundary.
1317    ///
1318    /// Adapters normally need only this method. State-only commands return
1319    /// immediately, action commands receive a deterministic operation identity,
1320    /// and prepared sequencer mutations remain durable if the executor fails.
1321    pub async fn run<E: GitFilesystemExecutor, T: IntoGitTreeRef>(
1322        &self,
1323        command: GitCommand,
1324        workspace_tree: T,
1325        executor: &E,
1326    ) -> Result<GitCommandOutput, GitCompatRunError<S::Error, E::Error>> {
1327        let workspace_tree = workspace_tree.into_git_tree_ref(self.workspace_id);
1328        self.validate_workspace_tree(workspace_tree).await?;
1329        executor
1330            .validate()
1331            .await
1332            .map_err(GitCompatRunError::Executor)?;
1333        match &command {
1334            GitCommand::MergeContinue => {
1335                return self.continue_join(workspace_tree, executor).await;
1336            }
1337            GitCommand::MergeAbort => return self.abort_join(executor).await,
1338            GitCommand::Add { .. } => {
1339                if let Some(pending) = self.pending_transition().await? {
1340                    if matches!(pending.mutation, GitPendingMutation::Join { .. }) {
1341                        // The compatibility working copy is always staged. During a
1342                        // conflicted join, `add` therefore records the user's intent
1343                        // without copying content; `merge --continue` captures the
1344                        // exact current workspace generation.
1345                        return Ok(GitCommandOutput::NoOp);
1346                    }
1347                    return Err(GitCompatError::TransitionPending(pending.id).into());
1348                }
1349            }
1350            _ => {}
1351        }
1352        let output = self.execute_validated(command, workspace_tree).await?;
1353        executor
1354            .validate()
1355            .await
1356            .map_err(GitCompatRunError::Executor)?;
1357        self.finish_output(output, workspace_tree, executor).await
1358    }
1359
1360    async fn continue_join<E: GitFilesystemExecutor>(
1361        &self,
1362        workspace_tree: GitTreeRef,
1363        executor: &E,
1364    ) -> Result<GitCommandOutput, GitCompatRunError<S::Error, E::Error>> {
1365        let pending = self.pending_transition().await?.ok_or_else(|| {
1366            GitCompatError::InvalidCommand("merge --continue requires a pending merge".to_owned())
1367        })?;
1368        if !matches!(pending.mutation, GitPendingMutation::Join { .. }) {
1369            return Err(GitCompatError::TransitionPending(pending.id).into());
1370        }
1371        let GitFilesystemAction::Join { tracked_paths, .. } = &pending.action else {
1372            return Err(GitCompatError::InvalidState.into());
1373        };
1374        executor
1375            .validate()
1376            .await
1377            .map_err(GitCompatRunError::Executor)?;
1378        self.complete_transition_result(
1379            pending.id,
1380            &GitFilesystemResult::Applied {
1381                tree: Some(workspace_tree),
1382                tracked_paths: Some(tracked_paths.clone()),
1383            },
1384        )
1385        .await
1386        .map_err(Into::into)
1387    }
1388
1389    async fn abort_join<E: GitFilesystemExecutor>(
1390        &self,
1391        executor: &E,
1392    ) -> Result<GitCommandOutput, GitCompatRunError<S::Error, E::Error>> {
1393        let pending = self.pending_transition().await?.ok_or_else(|| {
1394            GitCompatError::InvalidCommand("merge --abort requires a pending merge".to_owned())
1395        })?;
1396        if !matches!(pending.mutation, GitPendingMutation::Join { .. }) {
1397            return Err(GitCompatError::TransitionPending(pending.id).into());
1398        }
1399        let GitFilesystemAction::Join { target_tree, .. } = pending.action else {
1400            return Err(GitCompatError::InvalidState.into());
1401        };
1402        let mut operation_bytes = Vec::with_capacity(21);
1403        operation_bytes.extend_from_slice(&pending.id.into_bytes());
1404        operation_bytes.extend_from_slice(b"abort");
1405        let digest = blake3::hash(&operation_bytes);
1406        let mut operation_id = [0_u8; 16];
1407        operation_id.copy_from_slice(&digest.as_bytes()[..16]);
1408        let result = executor
1409            .execute(
1410                OperationId::from_bytes(operation_id),
1411                &GitFilesystemAction::RestoreGeneration {
1412                    tree: target_tree,
1413                    paths: None,
1414                },
1415            )
1416            .await
1417            .map_err(GitCompatRunError::Executor)?;
1418        executor
1419            .validate()
1420            .await
1421            .map_err(GitCompatRunError::Executor)?;
1422        self.abort_transition(pending.id).await?;
1423        Ok(GitCommandOutput::Filesystem(result))
1424    }
1425
1426    /// Parses argv following the `acyclic git` umbrella subcommand and executes
1427    /// it through [`Self::run`]. This API never intercepts or invokes system
1428    /// `git`.
1429    pub async fn run_argv<E: GitFilesystemExecutor, T: IntoGitTreeRef>(
1430        &self,
1431        argv: &[String],
1432        workspace_tree: T,
1433        default_author: &str,
1434        now_seconds: i64,
1435        executor: &E,
1436    ) -> Result<GitCommandOutput, GitCompatRunError<S::Error, E::Error>> {
1437        let command = parse_argv(argv, default_author, now_seconds)?;
1438        self.run(command, workspace_tree, executor).await
1439    }
1440
1441    /// Recovers the durable pending transition, if any, through the same
1442    /// executor contract used by [`Self::run`].
1443    pub async fn resume<E: GitFilesystemExecutor>(
1444        &self,
1445        executor: &E,
1446    ) -> Result<Option<GitCommandOutput>, GitCompatRunError<S::Error, E::Error>> {
1447        let Some(pending) = self.pending_transition().await? else {
1448            return Ok(None);
1449        };
1450        let result = executor
1451            .execute(pending.id.operation_id(), &pending.action)
1452            .await
1453            .map_err(GitCompatRunError::Executor)?;
1454        executor
1455            .validate()
1456            .await
1457            .map_err(GitCompatRunError::Executor)?;
1458        let output = self.complete_transition_result(pending.id, &result).await?;
1459        Ok(Some(if matches!(output, GitCommandOutput::NoOp) {
1460            GitCommandOutput::Filesystem(result)
1461        } else {
1462            output
1463        }))
1464    }
1465
1466    async fn finish_output<E: GitFilesystemExecutor>(
1467        &self,
1468        output: GitCommandOutput,
1469        workspace_tree: GitTreeRef,
1470        executor: &E,
1471    ) -> Result<GitCommandOutput, GitCompatRunError<S::Error, E::Error>> {
1472        let (action, operation_id, transition) = match output {
1473            GitCommandOutput::Action(action) => {
1474                let operation_id = action_operation_id(self.workspace_id, workspace_tree, &action)?;
1475                (action, operation_id, None)
1476            }
1477            GitCommandOutput::Prepared { transition, action } => {
1478                (action, transition.operation_id(), Some(transition))
1479            }
1480            other => return Ok(other),
1481        };
1482        let result = executor
1483            .execute(operation_id, &action)
1484            .await
1485            .map_err(GitCompatRunError::Executor)?;
1486        executor
1487            .validate()
1488            .await
1489            .map_err(GitCompatRunError::Executor)?;
1490        if let Some(transition) = transition {
1491            let completed = self.complete_transition_result(transition, &result).await?;
1492            return Ok(if matches!(completed, GitCommandOutput::NoOp) {
1493                GitCommandOutput::Filesystem(result)
1494            } else {
1495                completed
1496            });
1497        }
1498        match (action, result) {
1499            (
1500                GitFilesystemAction::CaptureCommit {
1501                    workspace_tree,
1502                    message,
1503                    author,
1504                    authored_at_seconds,
1505                    expected_head,
1506                    ..
1507                },
1508                GitFilesystemResult::Captured {
1509                    tree,
1510                    tracked_paths,
1511                    proof,
1512                },
1513            ) => self
1514                .record_captured_commit(GitCommitRecord {
1515                    expected_head,
1516                    tree,
1517                    workspace_tree,
1518                    tracked_paths,
1519                    capture_proof: proof,
1520                    message,
1521                    author,
1522                    authored_at_seconds,
1523                })
1524                .await
1525                .map_err(Into::into),
1526            (
1527                GitFilesystemAction::ForkBranch {
1528                    branch,
1529                    head,
1530                    switch,
1531                    ..
1532                },
1533                GitFilesystemResult::Forked { workspace_id },
1534            ) => self
1535                .register_branch_workspace(branch, workspace_id, head, switch)
1536                .await
1537                .map_err(Into::into),
1538            (GitFilesystemAction::CaptureCommit { .. }, _)
1539            | (GitFilesystemAction::ForkBranch { .. }, _) => Err(GitCompatRunError::InvalidResult),
1540            (_, result) => Ok(GitCommandOutput::Filesystem(result)),
1541        }
1542    }
1543
1544    /// Executes one typed command against an exact live workspace generation.
1545    pub async fn execute<T: IntoGitTreeRef>(
1546        &self,
1547        command: GitCommand,
1548        workspace_tree: T,
1549    ) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
1550        let workspace_tree = workspace_tree.into_git_tree_ref(self.workspace_id);
1551        self.validate_workspace_tree(workspace_tree).await?;
1552        self.execute_validated(command, workspace_tree).await
1553    }
1554
1555    async fn execute_validated(
1556        &self,
1557        command: GitCommand,
1558        workspace_tree: GitTreeRef,
1559    ) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
1560        for _ in 0..MAXIMUM_CAS_ATTEMPTS {
1561            let mut state = self.load().await?;
1562            if let Some(pending) = &state.pending {
1563                return Err(GitCompatError::TransitionPending(pending.id));
1564            }
1565            let before = state.clone();
1566            let output = execute_command(&mut state, command.clone(), workspace_tree)
1567                .map_err(map_state_error)?;
1568            let output = match output {
1569                GitCommandOutput::Action(action) => {
1570                    prepare_transition(&mut state, action, GitPendingMutation::NoOp)
1571                }
1572                output => output,
1573            };
1574            if state == before {
1575                return Ok(output);
1576            }
1577            let expected = state.revision;
1578            state.revision = expected.saturating_add(1);
1579            if self.compare_and_swap_state(expected, state).await? {
1580                return Ok(output);
1581            }
1582        }
1583        Err(GitCompatError::Contended)
1584    }
1585
1586    async fn validate_workspace_tree(
1587        &self,
1588        workspace_tree: GitTreeRef,
1589    ) -> Result<(), GitCompatError<S::Error>> {
1590        let state = self.load().await?;
1591        if state
1592            .current()
1593            .map_err(|_| GitCompatError::InvalidState)?
1594            .workspace_id
1595            != workspace_tree.workspace_id()
1596        {
1597            return Err(GitCompatError::WorkspaceMismatch);
1598        }
1599        Ok(())
1600    }
1601
1602    /// Returns the durable sequencer transition that requires recovery.
1603    pub async fn pending_transition(
1604        &self,
1605    ) -> Result<Option<GitPendingTransition>, GitCompatError<S::Error>> {
1606        self.load().await.map(|state| state.pending)
1607    }
1608
1609    /// Commits a prepared transition after its filesystem action succeeds.
1610    pub async fn complete_transition(
1611        &self,
1612        transition: GitTransitionId,
1613        resulting_tree: Option<GitTreeRef>,
1614    ) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
1615        self.complete_transition_result(
1616            transition,
1617            &GitFilesystemResult::Applied {
1618                tree: resulting_tree,
1619                tracked_paths: None,
1620            },
1621        )
1622        .await
1623    }
1624
1625    /// Commits a prepared transition using the executor's complete typed result.
1626    ///
1627    /// Capture and branch transitions require their full result so recovery can
1628    /// publish compatibility state without reconstructing filesystem facts.
1629    pub async fn complete_transition_result(
1630        &self,
1631        transition: GitTransitionId,
1632        result: &GitFilesystemResult,
1633    ) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
1634        for _ in 0..MAXIMUM_CAS_ATTEMPTS {
1635            let mut state = self.load().await?;
1636            let pending = state
1637                .pending
1638                .clone()
1639                .filter(|pending| pending.id == transition)
1640                .ok_or(GitCompatError::StaleTransition)?;
1641            validate_completion_result(&state, &pending, result)?;
1642            let output = match complete_pending(&mut state, pending.mutation, result) {
1643                Ok(output) => Some(output),
1644                Err(GitCompatError::NothingToCommit) => None,
1645                Err(error) => return Err(error),
1646            };
1647            state.pending = None;
1648            let expected = state.revision;
1649            state.revision = expected.saturating_add(1);
1650            if self.compare_and_swap_state(expected, state).await? {
1651                return output.ok_or(GitCompatError::NothingToCommit);
1652            }
1653        }
1654        Err(GitCompatError::Contended)
1655    }
1656
1657    /// Clears an exact prepared transition after the executor proves that no
1658    /// filesystem effects remain (or after the materializer rolls them back).
1659    pub async fn abort_transition(
1660        &self,
1661        transition: GitTransitionId,
1662    ) -> Result<(), GitCompatError<S::Error>> {
1663        for _ in 0..MAXIMUM_CAS_ATTEMPTS {
1664            let mut state = self.load().await?;
1665            if state.pending.as_ref().map(|pending| pending.id) != Some(transition) {
1666                return Err(GitCompatError::StaleTransition);
1667            }
1668            state.pending = None;
1669            let expected = state.revision;
1670            state.revision = expected.saturating_add(1);
1671            if self.compare_and_swap_state(expected, state).await? {
1672                return Ok(());
1673            }
1674        }
1675        Err(GitCompatError::Contended)
1676    }
1677
1678    /// Parses and executes argv following the `acyclic git` umbrella
1679    /// subcommand. Callers strip the two-token prefix before invoking this
1680    /// method; bare system `git` is outside this façade.
1681    pub async fn execute_argv<T: IntoGitTreeRef>(
1682        &self,
1683        argv: &[String],
1684        workspace_tree: T,
1685        default_author: &str,
1686        now_seconds: i64,
1687    ) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
1688        let command = parse_argv(argv, default_author, now_seconds)?;
1689        self.execute(command, workspace_tree).await
1690    }
1691
1692    /// Completes a previously emitted [`GitFilesystemAction::ForkBranch`].
1693    ///
1694    /// The caller must create a real SDK child workspace first. Registration is
1695    /// idempotent for the same branch/workspace/head tuple and rejects any
1696    /// competing result.
1697    pub async fn register_branch_workspace(
1698        &self,
1699        branch: impl Into<String>,
1700        workspace_id: WorkspaceId,
1701        head: Option<GitCommitId>,
1702        switch: bool,
1703    ) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
1704        let branch = branch.into();
1705        for _ in 0..MAXIMUM_CAS_ATTEMPTS {
1706            let mut state = self.load().await?;
1707            let output = register_branch_workspace_state(
1708                &mut state,
1709                branch.clone(),
1710                workspace_id,
1711                head,
1712                switch,
1713            )?;
1714            let expected = state.revision;
1715            state.revision = expected.saturating_add(1);
1716            if self.compare_and_swap_state(expected, state).await? {
1717                return Ok(output);
1718            }
1719        }
1720        Err(GitCompatError::Contended)
1721    }
1722
1723    /// Records a compatibility commit after the core executor filters newly
1724    /// ignored paths and returns the exact eligible generation.
1725    pub async fn record_commit(
1726        &self,
1727        expected_head: Option<GitCommitId>,
1728        generation: GenerationId,
1729        tracked_paths: BTreeSet<String>,
1730        message: impl Into<String>,
1731        author: impl Into<String>,
1732        authored_at_seconds: i64,
1733    ) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
1734        self.record_captured_commit(GitCommitRecord {
1735            expected_head,
1736            tree: GitGenerationRef {
1737                workspace_id: self.workspace_id,
1738                generation,
1739            }
1740            .into(),
1741            workspace_tree: GitGenerationRef {
1742                workspace_id: self.workspace_id,
1743                generation,
1744            }
1745            .into(),
1746            tracked_paths,
1747            capture_proof: None,
1748            message: message.into(),
1749            author: author.into(),
1750            authored_at_seconds,
1751        })
1752        .await
1753    }
1754
1755    /// Records a merge/publication performed by the distributed workspace
1756    /// layer without reimplementing compatibility history in an adapter.
1757    pub async fn record_publication(
1758        &self,
1759        publication: GitPublicationRecord,
1760    ) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
1761        let GitPublicationRecord {
1762            tree,
1763            workspace_tree,
1764            source_head,
1765            tracked_paths,
1766            capture_proof,
1767            message,
1768            author,
1769            authored_at_seconds,
1770        } = publication;
1771        for _ in 0..MAXIMUM_CAS_ATTEMPTS {
1772            let mut state = self.load().await?;
1773            let current = state
1774                .current()
1775                .map_err(|_| GitCompatError::InvalidState)?
1776                .clone();
1777            if workspace_tree.workspace_id() != current.workspace_id {
1778                return Err(GitCompatError::WorkspaceMismatch);
1779            }
1780            if current
1781                .head
1782                .and_then(|head| state.commits.get(&head))
1783                .is_some_and(|commit| commit.tree == tree)
1784            {
1785                return Ok(GitCommandOutput::NoOp);
1786            }
1787            let mut parents = current.head.into_iter().collect::<Vec<_>>();
1788            if let Some(source_head) = source_head
1789                && !parents.contains(&source_head)
1790            {
1791                if !state.commits.contains_key(&source_head) {
1792                    state.external_parents.insert(source_head);
1793                }
1794                parents.push(source_head);
1795            }
1796            let previous_tree = current
1797                .head
1798                .and_then(|id| state.commits.get(&id).map(|commit| commit.tree));
1799            if !capture_proof_valid(tree, workspace_tree, capture_proof.as_ref(), previous_tree) {
1800                return Err(GitCompatError::WorkspaceMismatch);
1801            }
1802            let commit = GitCommit::new_with_capture_proof(
1803                tree,
1804                workspace_tree,
1805                capture_proof.clone(),
1806                tracked_paths.clone(),
1807                parents,
1808                author.clone(),
1809                authored_at_seconds,
1810                message.clone(),
1811            );
1812            state.reflog.insert(0, current.head);
1813            state.commits.insert(commit.id, commit.clone());
1814            let branch = state
1815                .current_mut()
1816                .map_err(|_| GitCompatError::InvalidState)?;
1817            branch.head = Some(commit.id);
1818            branch.tracked_paths = tracked_paths.clone();
1819            let expected = state.revision;
1820            state.revision = expected.saturating_add(1);
1821            if self.compare_and_swap_state(expected, state).await? {
1822                return Ok(GitCommandOutput::Committed(commit));
1823            }
1824        }
1825        Err(GitCompatError::Contended)
1826    }
1827
1828    async fn record_captured_commit(
1829        &self,
1830        record: GitCommitRecord,
1831    ) -> Result<GitCommandOutput, GitCompatError<S::Error>> {
1832        let GitCommitRecord {
1833            expected_head,
1834            tree,
1835            workspace_tree,
1836            tracked_paths,
1837            capture_proof,
1838            message,
1839            author,
1840            authored_at_seconds,
1841        } = record;
1842        for _ in 0..MAXIMUM_CAS_ATTEMPTS {
1843            let mut state = self.load().await?;
1844            let output = record_captured_commit_state(
1845                &mut state,
1846                GitCommitRecord {
1847                    expected_head,
1848                    tree,
1849                    workspace_tree,
1850                    tracked_paths: tracked_paths.clone(),
1851                    capture_proof: capture_proof.clone(),
1852                    message: message.clone(),
1853                    author: author.clone(),
1854                    authored_at_seconds,
1855                },
1856            )?;
1857            let expected = state.revision;
1858            state.revision = expected.saturating_add(1);
1859            if self.compare_and_swap_state(expected, state).await? {
1860                return Ok(output);
1861            }
1862        }
1863        Err(GitCompatError::Contended)
1864    }
1865
1866    async fn load(&self) -> Result<GitCompatState, GitCompatError<S::Error>> {
1867        let mut state = self
1868            .store
1869            .load(self.workspace_id)
1870            .await
1871            .map_err(GitCompatError::Store)?
1872            .unwrap_or_else(|| GitCompatState::new("main", self.workspace_id));
1873        if !(1..=STATE_VERSION).contains(&state.version) || state.current().is_err() {
1874            return Err(GitCompatError::InvalidState);
1875        }
1876        if state.version < 6 && !state.legacy_tracked_paths.is_empty() {
1877            let migrated = std::mem::take(&mut state.legacy_tracked_paths);
1878            state
1879                .current_mut()
1880                .map_err(|_| GitCompatError::InvalidState)?
1881                .tracked_paths = migrated;
1882        }
1883        state.version = STATE_VERSION;
1884        validate_git_state(&state).map_err(|()| GitCompatError::InvalidState)?;
1885        Ok(state)
1886    }
1887
1888    async fn compare_and_swap_state(
1889        &self,
1890        expected: u64,
1891        state: GitCompatState,
1892    ) -> Result<bool, GitCompatError<S::Error>> {
1893        validate_git_state(&state).map_err(|()| GitCompatError::InvalidState)?;
1894        self.store
1895            .compare_and_swap(self.workspace_id, expected, state)
1896            .await
1897            .map_err(GitCompatError::Store)
1898    }
1899}
1900
1901fn validate_completion_result<E: std::error::Error + 'static>(
1902    state: &GitCompatState,
1903    pending: &GitPendingTransition,
1904    result: &GitFilesystemResult,
1905) -> Result<(), GitCompatError<E>> {
1906    let workspace_id = state
1907        .current()
1908        .map_err(|_| GitCompatError::InvalidState)?
1909        .workspace_id;
1910    match result {
1911        GitFilesystemResult::Captured { tree, proof, .. } => {
1912            let GitPendingMutation::CaptureCommit { workspace_tree, .. } = &pending.mutation else {
1913                return Err(GitCompatError::WorkspaceMismatch);
1914            };
1915            let previous_tree = match &pending.mutation {
1916                GitPendingMutation::CaptureCommit { expected_head, .. } => {
1917                    expected_head.and_then(|id| state.commits.get(&id).map(|commit| commit.tree))
1918                }
1919                _ => None,
1920            };
1921            let reused_head = proof.is_none() && previous_tree == Some(*tree);
1922            let valid_capture =
1923                capture_proof_valid(*tree, *workspace_tree, proof.as_ref(), previous_tree)
1924                    && proof
1925                        .as_ref()
1926                        .is_none_or(|proof| proof.operation_id == pending.id.operation_id());
1927            if !reused_head && !valid_capture {
1928                return Err(GitCompatError::WorkspaceMismatch);
1929            }
1930        }
1931        _ if result
1932            .resulting_tree()
1933            .is_some_and(|tree| tree.workspace_id() != workspace_id) =>
1934        {
1935            return Err(GitCompatError::WorkspaceMismatch);
1936        }
1937        _ => {}
1938    }
1939    if let GitPendingMutation::CaptureCommit { workspace_tree, .. } = pending.mutation
1940        && workspace_tree.workspace_id() != workspace_id
1941    {
1942        return Err(GitCompatError::WorkspaceMismatch);
1943    }
1944    Ok(())
1945}
1946
1947fn capture_proof_valid(
1948    tree: GitTreeRef,
1949    workspace_tree: GitTreeRef,
1950    proof: Option<&GitCaptureProof>,
1951    previous_tree: Option<GitTreeRef>,
1952) -> bool {
1953    if tree.workspace_id() == workspace_tree.workspace_id() {
1954        return proof.is_none();
1955    }
1956    proof.is_some_and(|proof| proof.structurally_valid(tree, workspace_tree, previous_tree))
1957}
1958
1959fn validate_git_state(state: &GitCompatState) -> Result<(), ()> {
1960    if state.version != STATE_VERSION
1961        || state.branches.is_empty()
1962        || !state.branches.contains_key(&state.current_branch)
1963        || !state.legacy_tracked_paths.is_empty()
1964    {
1965        return Err(());
1966    }
1967    validate_git_branches(state)?;
1968    let known_workspaces: BTreeSet<_> = state
1969        .branches
1970        .values()
1971        .map(|branch| branch.workspace_id)
1972        .collect();
1973    validate_git_commits(state, &known_workspaces)?;
1974    validate_git_commit_graph(state)?;
1975    validate_git_references(state, &known_workspaces)
1976}
1977
1978fn validate_git_branches(state: &GitCompatState) -> Result<(), ()> {
1979    for (name, branch) in &state.branches {
1980        let head_paths = match branch.head {
1981            Some(head) => Some(&state.commits.get(&head).ok_or(())?.tracked_paths),
1982            None => None,
1983        };
1984        if name.is_empty()
1985            || branch.name != *name
1986            || head_paths.map_or(!branch.tracked_paths.is_empty(), |tracked_paths| {
1987                tracked_paths != &branch.tracked_paths
1988            })
1989        {
1990            return Err(());
1991        }
1992    }
1993    Ok(())
1994}
1995
1996fn validate_git_commits(
1997    state: &GitCompatState,
1998    known_workspaces: &BTreeSet<WorkspaceId>,
1999) -> Result<(), ()> {
2000    for (id, commit) in &state.commits {
2001        let rebuilt = GitCommit::new_with_capture_proof(
2002            commit.tree,
2003            commit.workspace_tree,
2004            commit.capture_proof.clone(),
2005            commit.tracked_paths.clone(),
2006            commit.parents.clone(),
2007            commit.author.clone(),
2008            commit.authored_at_seconds,
2009            commit.message.clone(),
2010        );
2011        if *id != commit.id
2012            || rebuilt.id != commit.id
2013            // A filtered compatibility tree may be a pinned SDK fork of the
2014            // live workspace when newly ignored paths are omitted. The live
2015            // tree must still belong to this repository's branch workspace.
2016            || !known_workspaces.contains(&commit.workspace_tree.workspace_id())
2017            || !capture_proof_valid(
2018                commit.tree,
2019                commit.workspace_tree,
2020                commit.capture_proof.as_ref(),
2021                commit.parents.first().and_then(|id| state.commits.get(id).map(|parent| parent.tree)),
2022            )
2023            || commit.parents.iter().any(|parent| {
2024                !state.commits.contains_key(parent) && !state.external_parents.contains(parent)
2025            })
2026        {
2027            return Err(());
2028        }
2029    }
2030    Ok(())
2031}
2032
2033fn validate_git_commit_graph(state: &GitCompatState) -> Result<(), ()> {
2034    let mut permanent = BTreeSet::new();
2035    for root in state.commits.keys().copied() {
2036        if permanent.contains(&root) {
2037            continue;
2038        }
2039        let mut active = BTreeSet::new();
2040        let mut stack = vec![(root, false)];
2041        while let Some((id, expanded)) = stack.pop() {
2042            if expanded {
2043                active.remove(&id);
2044                permanent.insert(id);
2045                continue;
2046            }
2047            if permanent.contains(&id) {
2048                continue;
2049            }
2050            if !active.insert(id) {
2051                return Err(());
2052            }
2053            stack.push((id, true));
2054            let commit = state.commits.get(&id).ok_or(())?;
2055            stack.extend(
2056                commit
2057                    .parents
2058                    .iter()
2059                    .rev()
2060                    .filter(|parent| state.commits.contains_key(parent))
2061                    .map(|parent| (*parent, false)),
2062            );
2063        }
2064    }
2065    Ok(())
2066}
2067
2068fn validate_git_references(
2069    state: &GitCompatState,
2070    known_workspaces: &BTreeSet<WorkspaceId>,
2071) -> Result<(), ()> {
2072    let known_commit = |id: &GitCommitId| state.commits.contains_key(id);
2073    let known_reference =
2074        |id: &GitCommitId| state.commits.contains_key(id) || state.external_parents.contains(id);
2075    if state.external_parents.iter().any(|external| {
2076        state.commits.contains_key(external)
2077            || !state
2078                .commits
2079                .values()
2080                .any(|commit| commit.parents.contains(external))
2081    }) {
2082        return Err(());
2083    }
2084    if state.tags.values().any(|id| !known_commit(id))
2085        || state.reflog.iter().flatten().any(|id| !known_commit(id))
2086        || state
2087            .stash
2088            .iter()
2089            .any(|tree| !known_workspaces.contains(&tree.workspace_id()))
2090        || state.bisect.as_ref().is_some_and(|bisect| {
2091            !known_commit(&bisect.original_head)
2092                || !known_commit(&bisect.bad)
2093                || bisect.good.as_ref().is_some_and(|id| !known_commit(id))
2094                || bisect.current.as_ref().is_some_and(|id| !known_commit(id))
2095                || bisect.skipped.iter().any(|id| !known_commit(id))
2096        })
2097    {
2098        return Err(());
2099    }
2100    if let Some(pending) = &state.pending {
2101        let valid = match &pending.mutation {
2102            GitPendingMutation::CaptureCommit {
2103                workspace_tree,
2104                expected_head,
2105                ..
2106            } => {
2107                workspace_tree.workspace_id() == state.current().map_err(|_| ())?.workspace_id
2108                    && expected_head.as_ref().is_none_or(known_commit)
2109            }
2110            GitPendingMutation::ForkBranch { branch, head, .. } => {
2111                !branch.is_empty()
2112                    && !state.branches.contains_key(branch)
2113                    && head.as_ref().is_none_or(known_commit)
2114            }
2115            GitPendingMutation::Switch { branch } => state.branches.contains_key(branch),
2116            GitPendingMutation::Reset { head }
2117            | GitPendingMutation::ApplyCommit { commit: head, .. } => known_commit(head),
2118            GitPendingMutation::Join { source_head, .. } => {
2119                source_head.as_ref().is_none_or(known_reference)
2120            }
2121            GitPendingMutation::NoOp
2122            | GitPendingMutation::StashPush { .. }
2123            | GitPendingMutation::StashPop { .. }
2124            | GitPendingMutation::Bisect { .. } => true,
2125        };
2126        if !valid {
2127            return Err(());
2128        }
2129    }
2130    Ok(())
2131}
2132
2133fn action_operation_id(
2134    workspace_id: WorkspaceId,
2135    workspace_tree: GitTreeRef,
2136    action: &GitFilesystemAction,
2137) -> Result<OperationId, serde_json::Error> {
2138    let mut hasher = blake3::Hasher::new();
2139    hasher.update(ACTION_DOMAIN);
2140    hasher.update(&workspace_id.into_bytes());
2141    hash_tree_ref(&mut hasher, workspace_tree);
2142    let encoded = serde_json::to_vec(action)?;
2143    hash_bytes(&mut hasher, &encoded);
2144    let mut operation = [0; 16];
2145    operation.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
2146    Ok(OperationId::from_bytes(operation))
2147}
2148
2149#[allow(clippy::too_many_lines)]
2150fn execute_command(
2151    state: &mut GitCompatState,
2152    command: GitCommand,
2153    workspace: GitTreeRef,
2154) -> Result<GitCommandOutput, GitCompatStateError> {
2155    let current = state.current()?.clone();
2156    if state.bisect.is_some()
2157        && !matches!(
2158            &command,
2159            GitCommand::Status
2160                | GitCommand::Diff { .. }
2161                | GitCommand::Log { .. }
2162                | GitCommand::Show { .. }
2163                | GitCommand::RevParse { .. }
2164                | GitCommand::SymbolicRef { .. }
2165                | GitCommand::MergeBase { .. }
2166                | GitCommand::LsFiles
2167                | GitCommand::CheckIgnore { .. }
2168                | GitCommand::Bisect { .. }
2169        )
2170    {
2171        return Err(GitCompatStateError::Bisect(
2172            "finish with 'git bisect reset' before changing history".to_owned(),
2173        ));
2174    }
2175    let visible_head = state
2176        .bisect
2177        .as_ref()
2178        .and_then(|bisect| bisect.current)
2179        .or(current.head);
2180    let head_workspace_tree =
2181        visible_head.and_then(|id| state.commits.get(&id).map(|commit| commit.workspace_tree));
2182    let head_tree = visible_head.and_then(|id| state.commits.get(&id).map(|commit| commit.tree));
2183    Ok(match command {
2184        GitCommand::MergeContinue | GitCommand::MergeAbort => {
2185            return Err(GitCompatStateError::Invalid);
2186        }
2187        GitCommand::Status => GitCommandOutput::Status(GitStatus {
2188            branch: current.name,
2189            head: visible_head,
2190            workspace,
2191            dirty: match head_workspace_tree {
2192                Some(head) if head == workspace => GitDirtyState::Clean,
2193                Some(GitTreeRef::Lazy(_)) | None if matches!(workspace, GitTreeRef::Lazy(_)) => {
2194                    GitDirtyState::Unknown
2195                }
2196                _ => GitDirtyState::Dirty,
2197            },
2198            all_changes_staged: true,
2199        }),
2200        GitCommand::Diff { .. } => GitCommandOutput::Action(GitFilesystemAction::Diff {
2201            from: head_tree,
2202            to: workspace,
2203            tracked_paths: current.tracked_paths,
2204        }),
2205        GitCommand::Log { maximum } => {
2206            GitCommandOutput::Commits(walk_commits(state, current.head, maximum))
2207        }
2208        GitCommand::Show { object } => {
2209            let id = resolve_object(state, object.as_ref())?;
2210            GitCommandOutput::Commits(vec![
2211                state
2212                    .commits
2213                    .get(&id)
2214                    .cloned()
2215                    .ok_or(GitCompatStateError::Invalid)?,
2216            ])
2217        }
2218        GitCommand::Add { .. } => GitCommandOutput::NoOp,
2219        GitCommand::Commit {
2220            message,
2221            author,
2222            authored_at_seconds,
2223        } => {
2224            if head_workspace_tree == Some(workspace) {
2225                return Err(GitCompatStateError::NothingToCommit);
2226            }
2227            let action = GitFilesystemAction::CaptureCommit {
2228                workspace_tree: workspace,
2229                head_tree,
2230                head_workspace_tree: Box::new(head_workspace_tree),
2231                tracked_paths: current.tracked_paths.clone(),
2232                message: message.clone(),
2233                author: author.clone(),
2234                authored_at_seconds,
2235                expected_head: current.head,
2236            };
2237            prepare_transition(
2238                state,
2239                action,
2240                GitPendingMutation::CaptureCommit {
2241                    workspace_tree: workspace,
2242                    message,
2243                    author,
2244                    authored_at_seconds,
2245                    expected_head: current.head,
2246                },
2247            )
2248        }
2249        GitCommand::Branch { create } => {
2250            if let Some(name) = create {
2251                if state.branches.contains_key(&name) {
2252                    return Err(GitCompatStateError::BranchExists(name));
2253                }
2254                let action = GitFilesystemAction::ForkBranch {
2255                    branch: name.clone(),
2256                    source_tree: workspace,
2257                    head: current.head,
2258                    switch: false,
2259                };
2260                return Ok(prepare_transition(
2261                    state,
2262                    action,
2263                    GitPendingMutation::ForkBranch {
2264                        branch: name,
2265                        head: current.head,
2266                        switch: false,
2267                    },
2268                ));
2269            }
2270            GitCommandOutput::Branches {
2271                current: state.current_branch.clone(),
2272                branches: state.branches.values().cloned().collect(),
2273            }
2274        }
2275        GitCommand::Switch { branch, create } => {
2276            if create && !state.branches.contains_key(&branch) {
2277                let action = GitFilesystemAction::ForkBranch {
2278                    branch: branch.clone(),
2279                    source_tree: workspace,
2280                    head: current.head,
2281                    switch: true,
2282                };
2283                return Ok(prepare_transition(
2284                    state,
2285                    action,
2286                    GitPendingMutation::ForkBranch {
2287                        branch,
2288                        head: current.head,
2289                        switch: true,
2290                    },
2291                ));
2292            }
2293            let target = state
2294                .branches
2295                .get(&branch)
2296                .ok_or_else(|| GitCompatStateError::Unknown(branch.clone()))?;
2297            prepare_transition(
2298                state,
2299                GitFilesystemAction::SwitchWorkspace {
2300                    workspace_id: target.workspace_id,
2301                },
2302                GitPendingMutation::Switch { branch },
2303            )
2304        }
2305        GitCommand::Restore { source, paths } => {
2306            let id = resolve_object(state, source.as_ref())?;
2307            let commit = state.commits.get(&id).ok_or(GitCompatStateError::Invalid)?;
2308            GitCommandOutput::Action(GitFilesystemAction::RestorePaths {
2309                tree: commit.tree,
2310                paths: expand_pathspecs(&paths, &commit.tracked_paths),
2311            })
2312        }
2313        GitCommand::Reset { target, mode } => {
2314            let id = resolve_object(state, Some(&target))?;
2315            let commit = state.commits.get(&id).ok_or(GitCompatStateError::Invalid)?;
2316            let tree = commit.tree;
2317            let target_tracked_paths = commit.tracked_paths.clone();
2318            match mode {
2319                GitResetMode::Soft | GitResetMode::Mixed => {
2320                    state.reflog.insert(0, current.head);
2321                    let branch = state.current_mut()?;
2322                    branch.head = Some(id);
2323                    branch.tracked_paths = target_tracked_paths;
2324                    GitCommandOutput::NoOp
2325                }
2326                GitResetMode::Hard => prepare_transition(
2327                    state,
2328                    GitFilesystemAction::RestoreGeneration {
2329                        tree,
2330                        paths: Some(
2331                            current
2332                                .tracked_paths
2333                                .union(&target_tracked_paths)
2334                                .cloned()
2335                                .collect(),
2336                        ),
2337                    },
2338                    GitPendingMutation::Reset { head: id },
2339                ),
2340            }
2341        }
2342        GitCommand::Merge { branch } => {
2343            let source = state
2344                .branches
2345                .get(&branch)
2346                .ok_or_else(|| GitCompatStateError::Unknown(branch.clone()))?;
2347            let tracked_paths = current
2348                .tracked_paths
2349                .union(&source.tracked_paths)
2350                .cloned()
2351                .collect();
2352            prepare_transition(
2353                state,
2354                GitFilesystemAction::Join {
2355                    target_tree: workspace,
2356                    source_workspace: source.workspace_id,
2357                    rebase: false,
2358                    tracked_paths,
2359                },
2360                GitPendingMutation::Join {
2361                    source_head: source.head,
2362                    source_branch: branch,
2363                    rebase: false,
2364                },
2365            )
2366        }
2367        GitCommand::Rebase { branch } => {
2368            let source = state
2369                .branches
2370                .get(&branch)
2371                .ok_or_else(|| GitCompatStateError::Unknown(branch.clone()))?;
2372            let tracked_paths = current
2373                .tracked_paths
2374                .union(&source.tracked_paths)
2375                .cloned()
2376                .collect();
2377            prepare_transition(
2378                state,
2379                GitFilesystemAction::Join {
2380                    target_tree: workspace,
2381                    source_workspace: source.workspace_id,
2382                    rebase: true,
2383                    tracked_paths,
2384                },
2385                GitPendingMutation::Join {
2386                    source_head: source.head,
2387                    source_branch: branch,
2388                    rebase: true,
2389                },
2390            )
2391        }
2392        GitCommand::StashPush => {
2393            let tree = head_workspace_tree.ok_or(GitCompatStateError::UnbornHead)?;
2394            prepare_transition(
2395                state,
2396                GitFilesystemAction::RestoreGeneration { tree, paths: None },
2397                GitPendingMutation::StashPush { tree: workspace },
2398            )
2399        }
2400        GitCommand::StashPop => {
2401            let tree = state
2402                .stash
2403                .last()
2404                .copied()
2405                .ok_or(GitCompatStateError::EmptyStash)?;
2406            prepare_transition(
2407                state,
2408                GitFilesystemAction::RestoreGeneration { tree, paths: None },
2409                GitPendingMutation::StashPop { tree },
2410            )
2411        }
2412        GitCommand::CherryPick { object } => {
2413            let commit = resolve_object(state, Some(&object))?;
2414            let record = state
2415                .commits
2416                .get(&commit)
2417                .ok_or(GitCompatStateError::Invalid)?;
2418            let parent = record
2419                .parents
2420                .first()
2421                .and_then(|parent| state.commits.get(parent));
2422            let base = parent.map(|commit| commit_generation_ref(commit, current.workspace_id));
2423            let source = Some(commit_generation_ref(record, current.workspace_id));
2424            let paths = parent.map_or_else(
2425                || record.tracked_paths.clone(),
2426                |parent| {
2427                    parent
2428                        .tracked_paths
2429                        .union(&record.tracked_paths)
2430                        .cloned()
2431                        .collect()
2432                },
2433            );
2434            let tracked_paths = current.tracked_paths.union(&paths).cloned().collect();
2435            prepare_transition(
2436                state,
2437                GitFilesystemAction::ApplyCommit {
2438                    commit,
2439                    reverse: false,
2440                    base,
2441                    source,
2442                    paths,
2443                    tracked_paths,
2444                },
2445                GitPendingMutation::ApplyCommit {
2446                    commit,
2447                    reverse: false,
2448                },
2449            )
2450        }
2451        GitCommand::Revert { object } => {
2452            let commit = resolve_object(state, Some(&object))?;
2453            let record = state
2454                .commits
2455                .get(&commit)
2456                .ok_or(GitCompatStateError::Invalid)?;
2457            let parent = record
2458                .parents
2459                .first()
2460                .and_then(|parent| state.commits.get(parent));
2461            let base = Some(commit_generation_ref(record, current.workspace_id));
2462            let source = parent.map(|commit| commit_generation_ref(commit, current.workspace_id));
2463            let paths = parent.map_or_else(
2464                || record.tracked_paths.clone(),
2465                |parent| {
2466                    parent
2467                        .tracked_paths
2468                        .union(&record.tracked_paths)
2469                        .cloned()
2470                        .collect()
2471                },
2472            );
2473            let tracked_paths = current.tracked_paths.union(&paths).cloned().collect();
2474            prepare_transition(
2475                state,
2476                GitFilesystemAction::ApplyCommit {
2477                    commit,
2478                    reverse: true,
2479                    base,
2480                    source,
2481                    paths,
2482                    tracked_paths,
2483                },
2484                GitPendingMutation::ApplyCommit {
2485                    commit,
2486                    reverse: true,
2487                },
2488            )
2489        }
2490        GitCommand::Tag {
2491            name,
2492            target,
2493            delete,
2494        } => {
2495            if let Some(name) = name {
2496                if delete {
2497                    state.tags.remove(&name);
2498                } else {
2499                    let id = resolve_object(state, target.as_ref())?;
2500                    state.tags.insert(name, id);
2501                }
2502            }
2503            GitCommandOutput::Tags(state.tags.clone())
2504        }
2505        GitCommand::Blame { path } => GitCommandOutput::Action(GitFilesystemAction::Blame {
2506            path,
2507            commits: walk_commits(state, current.head, u32::MAX),
2508        }),
2509        GitCommand::Grep { pattern, path } => GitCommandOutput::Action(GitFilesystemAction::Grep {
2510            pattern,
2511            path,
2512            tree: workspace,
2513        }),
2514        GitCommand::Clean { dry_run } => GitCommandOutput::Action(GitFilesystemAction::Clean {
2515            dry_run,
2516            tree: workspace,
2517            tracked_paths: current.tracked_paths,
2518        }),
2519        GitCommand::Archive { object } => {
2520            let snapshot = match object {
2521                Some(object) => {
2522                    let id = resolve_object(state, Some(&object))?;
2523                    let commit = state.commits.get(&id).ok_or(GitCompatStateError::Invalid)?;
2524                    commit.tree
2525                }
2526                None => workspace,
2527            };
2528            GitCommandOutput::Action(GitFilesystemAction::Archive { tree: snapshot })
2529        }
2530        GitCommand::Apply { patch } => {
2531            GitCommandOutput::Action(GitFilesystemAction::ApplyPatch { patch })
2532        }
2533        GitCommand::Bisect { arguments } => execute_bisect(state, &current, workspace, &arguments)?,
2534        GitCommand::RevParse { argument } => {
2535            let value = match argument.as_str() {
2536                "--is-inside-work-tree" => "true".to_owned(),
2537                "--abbrev-ref HEAD" => current.name,
2538                "--symbolic-full-name HEAD" => format!("refs/heads/{}", current.name),
2539                _ => resolve_object(state, Some(&GitObjectName(argument)))?.to_hex(),
2540            };
2541            GitCommandOutput::Text(value)
2542        }
2543        GitCommand::SymbolicRef { short } => GitCommandOutput::Text(if short {
2544            current.name
2545        } else {
2546            format!("refs/heads/{}", current.name)
2547        }),
2548        GitCommand::MergeBase { left, right } => {
2549            let left = resolve_object(state, Some(&left))?;
2550            let right = resolve_object(state, Some(&right))?;
2551            let common = nearest_common_ancestor(state, left, right)
2552                .ok_or_else(|| GitCompatStateError::Unknown("merge base".to_owned()))?;
2553            GitCommandOutput::Text(common.to_hex())
2554        }
2555        GitCommand::LsFiles => GitCommandOutput::Paths(current.tracked_paths.into_iter().collect()),
2556        GitCommand::CheckIgnore { paths } => {
2557            GitCommandOutput::Action(GitFilesystemAction::CheckIgnore {
2558                paths,
2559                tree: workspace,
2560            })
2561        }
2562    })
2563}
2564
2565fn nearest_common_ancestor(
2566    state: &GitCompatState,
2567    left: GitCommitId,
2568    right: GitCommitId,
2569) -> Option<GitCommitId> {
2570    fn distances(state: &GitCompatState, start: GitCommitId) -> BTreeMap<GitCommitId, u32> {
2571        let mut result = BTreeMap::new();
2572        let mut pending = std::collections::VecDeque::from([(start, 0_u32)]);
2573        while let Some((commit, distance)) = pending.pop_front() {
2574            if result.get(&commit).is_some_and(|known| *known <= distance) {
2575                continue;
2576            }
2577            result.insert(commit, distance);
2578            if let Some(record) = state.commits.get(&commit) {
2579                pending.extend(
2580                    record
2581                        .parents
2582                        .iter()
2583                        .copied()
2584                        .map(|parent| (parent, distance.saturating_add(1))),
2585                );
2586            }
2587        }
2588        result
2589    }
2590
2591    let left_distances = distances(state, left);
2592    let right_distances = distances(state, right);
2593    left_distances
2594        .iter()
2595        .filter_map(|(commit, left_distance)| {
2596            right_distances
2597                .get(commit)
2598                .map(|right_distance| (*commit, left_distance + right_distance, *left_distance))
2599        })
2600        .min_by_key(|(commit, total, left_distance)| (*total, *left_distance, *commit))
2601        .map(|(commit, _, _)| commit)
2602}
2603
2604fn prepare_transition(
2605    state: &mut GitCompatState,
2606    action: GitFilesystemAction,
2607    mutation: GitPendingMutation,
2608) -> GitCommandOutput {
2609    let transition = GitTransitionId::new();
2610    state.pending = Some(GitPendingTransition {
2611        id: transition,
2612        action: action.clone(),
2613        mutation,
2614    });
2615    GitCommandOutput::Prepared { transition, action }
2616}
2617
2618fn register_branch_workspace_state<E: std::error::Error + 'static>(
2619    state: &mut GitCompatState,
2620    branch: String,
2621    workspace_id: WorkspaceId,
2622    head: Option<GitCommitId>,
2623    switch: bool,
2624) -> Result<GitCommandOutput, GitCompatError<E>> {
2625    if let Some(existing) = state.branches.get(&branch) {
2626        if existing.workspace_id != workspace_id || existing.head != head {
2627            return Err(GitCompatError::BranchExists(branch));
2628        }
2629        if switch {
2630            state.current_branch = branch;
2631        }
2632        return Ok(GitCommandOutput::NoOp);
2633    }
2634    let tracked_paths = head
2635        .and_then(|head| state.commits.get(&head))
2636        .map(|commit| commit.tracked_paths.clone())
2637        .unwrap_or_default();
2638    state.branches.insert(
2639        branch.clone(),
2640        GitBranch {
2641            name: branch.clone(),
2642            workspace_id,
2643            head,
2644            tracked_paths,
2645        },
2646    );
2647    if switch {
2648        state.current_branch = branch;
2649    }
2650    Ok(GitCommandOutput::NoOp)
2651}
2652
2653fn record_captured_commit_state<E: std::error::Error + 'static>(
2654    state: &mut GitCompatState,
2655    record: GitCommitRecord,
2656) -> Result<GitCommandOutput, GitCompatError<E>> {
2657    let current = state
2658        .current()
2659        .map_err(|_| GitCompatError::InvalidState)?
2660        .clone();
2661    if current.head != record.expected_head {
2662        return Err(GitCompatError::InvalidState);
2663    }
2664    let head_tree = current
2665        .head
2666        .and_then(|id| state.commits.get(&id).map(|commit| commit.tree));
2667    if head_tree == Some(record.tree) {
2668        return Err(GitCompatError::NothingToCommit);
2669    }
2670    if !capture_proof_valid(
2671        record.tree,
2672        record.workspace_tree,
2673        record.capture_proof.as_ref(),
2674        head_tree,
2675    ) {
2676        return Err(GitCompatError::WorkspaceMismatch);
2677    }
2678    let commit = GitCommit::new_with_capture_proof(
2679        record.tree,
2680        record.workspace_tree,
2681        record.capture_proof,
2682        record.tracked_paths.clone(),
2683        current.head.into_iter().collect(),
2684        record.author,
2685        record.authored_at_seconds,
2686        record.message,
2687    );
2688    state.reflog.insert(0, current.head);
2689    state.commits.insert(commit.id, commit.clone());
2690    let current = state
2691        .current_mut()
2692        .map_err(|_| GitCompatError::InvalidState)?;
2693    current.head = Some(commit.id);
2694    current.tracked_paths = record.tracked_paths;
2695    Ok(GitCommandOutput::Committed(commit))
2696}
2697
2698#[allow(clippy::too_many_lines)]
2699fn complete_pending<E: std::error::Error + 'static>(
2700    state: &mut GitCompatState,
2701    mutation: GitPendingMutation,
2702    result: &GitFilesystemResult,
2703) -> Result<GitCommandOutput, GitCompatError<E>> {
2704    let resulting_tree = result.resulting_tree();
2705    match mutation {
2706        GitPendingMutation::NoOp => Ok(GitCommandOutput::NoOp),
2707        mutation @ (GitPendingMutation::CaptureCommit { .. }
2708        | GitPendingMutation::ForkBranch { .. }) => {
2709            complete_creation_pending(state, mutation, result)
2710        }
2711        GitPendingMutation::Switch { branch } => {
2712            if !state.branches.contains_key(&branch) {
2713                return Err(GitCompatError::InvalidState);
2714            }
2715            state.current_branch = branch;
2716            Ok(GitCommandOutput::NoOp)
2717        }
2718        GitPendingMutation::Reset { head } => {
2719            let previous = state
2720                .current()
2721                .map_err(|_| GitCompatError::InvalidState)?
2722                .head;
2723            state.reflog.insert(0, previous);
2724            let tracked_paths = state
2725                .commits
2726                .get(&head)
2727                .ok_or(GitCompatError::InvalidState)?
2728                .tracked_paths
2729                .clone();
2730            let branch = state
2731                .current_mut()
2732                .map_err(|_| GitCompatError::InvalidState)?;
2733            branch.head = Some(head);
2734            branch.tracked_paths = tracked_paths;
2735            Ok(GitCommandOutput::NoOp)
2736        }
2737        GitPendingMutation::StashPush { tree } => {
2738            state.stash.push(tree);
2739            Ok(GitCommandOutput::NoOp)
2740        }
2741        GitPendingMutation::StashPop { tree } => {
2742            if state.stash.last() != Some(&tree) {
2743                return Err(GitCompatError::InvalidState);
2744            }
2745            state.stash.pop();
2746            Ok(GitCommandOutput::NoOp)
2747        }
2748        GitPendingMutation::Join {
2749            source_head,
2750            source_branch,
2751            rebase,
2752        } => {
2753            let tree = resulting_tree.ok_or(GitCompatError::MissingResultGeneration)?;
2754            let tracked_paths = match result {
2755                GitFilesystemResult::Applied {
2756                    tracked_paths: Some(paths),
2757                    ..
2758                } => paths.clone(),
2759                _ => return Err(GitCompatError::InvalidState),
2760            };
2761            let previous = state
2762                .current()
2763                .map_err(|_| GitCompatError::InvalidState)?
2764                .head;
2765            let parents = if rebase {
2766                source_head.into_iter().collect()
2767            } else {
2768                previous.into_iter().chain(source_head).collect()
2769            };
2770            let verb = if rebase { "rebase" } else { "merge" };
2771            record_generated_commit(
2772                state,
2773                tree,
2774                parents,
2775                tracked_paths,
2776                format!("{verb} {source_branch}"),
2777                previous,
2778            )
2779        }
2780        GitPendingMutation::ApplyCommit { commit, reverse } => {
2781            let tree = resulting_tree.ok_or(GitCompatError::MissingResultGeneration)?;
2782            let tracked_paths = match result {
2783                GitFilesystemResult::Applied {
2784                    tracked_paths: Some(paths),
2785                    ..
2786                } => paths.clone(),
2787                _ => return Err(GitCompatError::InvalidState),
2788            };
2789            let previous = state
2790                .current()
2791                .map_err(|_| GitCompatError::InvalidState)?
2792                .head;
2793            let verb = if reverse { "revert" } else { "cherry-pick" };
2794            record_generated_commit(
2795                state,
2796                tree,
2797                previous.into_iter().collect(),
2798                tracked_paths,
2799                format!("{verb} {}", commit.to_hex()),
2800                previous,
2801            )
2802        }
2803        GitPendingMutation::Bisect {
2804            state: replacement,
2805            result,
2806        } => {
2807            state.bisect = *replacement;
2808            Ok(GitCommandOutput::Bisect(result))
2809        }
2810    }
2811}
2812
2813fn complete_creation_pending<E: std::error::Error + 'static>(
2814    state: &mut GitCompatState,
2815    mutation: GitPendingMutation,
2816    result: &GitFilesystemResult,
2817) -> Result<GitCommandOutput, GitCompatError<E>> {
2818    match (mutation, result) {
2819        (
2820            GitPendingMutation::CaptureCommit {
2821                workspace_tree,
2822                message,
2823                author,
2824                authored_at_seconds,
2825                expected_head,
2826            },
2827            GitFilesystemResult::Captured {
2828                tree,
2829                tracked_paths,
2830                proof,
2831            },
2832        ) => record_captured_commit_state(
2833            state,
2834            GitCommitRecord {
2835                expected_head,
2836                tree: *tree,
2837                workspace_tree,
2838                tracked_paths: tracked_paths.clone(),
2839                capture_proof: proof.clone(),
2840                message,
2841                author,
2842                authored_at_seconds,
2843            },
2844        ),
2845        (
2846            GitPendingMutation::ForkBranch {
2847                branch,
2848                head,
2849                switch,
2850            },
2851            GitFilesystemResult::Forked { workspace_id },
2852        ) => register_branch_workspace_state(state, branch, *workspace_id, head, switch),
2853        _ => Err(GitCompatError::InvalidState),
2854    }
2855}
2856
2857fn record_generated_commit<E: std::error::Error + 'static>(
2858    state: &mut GitCompatState,
2859    tree: GitTreeRef,
2860    parents: Vec<GitCommitId>,
2861    tracked_paths: BTreeSet<String>,
2862    message: String,
2863    previous: Option<GitCommitId>,
2864) -> Result<GitCommandOutput, GitCompatError<E>> {
2865    let commit = GitCommit::new_with_metadata(
2866        tree,
2867        tree,
2868        tracked_paths.clone(),
2869        parents,
2870        "git-compat",
2871        0,
2872        message,
2873    );
2874    state.reflog.insert(0, previous);
2875    state.commits.insert(commit.id, commit.clone());
2876    let branch = state
2877        .current_mut()
2878        .map_err(|_| GitCompatError::InvalidState)?;
2879    branch.head = Some(commit.id);
2880    branch.tracked_paths = tracked_paths;
2881    Ok(GitCommandOutput::Committed(commit))
2882}
2883
2884fn map_state_error<E: std::error::Error + 'static>(
2885    error: GitCompatStateError,
2886) -> GitCompatError<E> {
2887    match error {
2888        GitCompatStateError::Invalid => GitCompatError::InvalidState,
2889        GitCompatStateError::Unknown(name) => GitCompatError::UnknownObject(name),
2890        GitCompatStateError::BranchExists(name) => GitCompatError::BranchExists(name),
2891        GitCompatStateError::UnbornHead => GitCompatError::UnbornHead,
2892        GitCompatStateError::NothingToCommit => GitCompatError::NothingToCommit,
2893        GitCompatStateError::EmptyStash => GitCompatError::UnknownObject("stash@{0}".to_owned()),
2894        GitCompatStateError::Bisect(message) => GitCompatError::InvalidCommand(message),
2895    }
2896}
2897
2898fn resolve_object(
2899    state: &GitCompatState,
2900    object: Option<&GitObjectName>,
2901) -> Result<GitCommitId, GitCompatStateError> {
2902    let Some(object) = object else {
2903        return visible_head(state).ok_or(GitCompatStateError::UnbornHead);
2904    };
2905    if object.0 == "HEAD" {
2906        return visible_head(state).ok_or(GitCompatStateError::UnbornHead);
2907    }
2908    if let Some(branch) = state.branches.get(&object.0) {
2909        return branch.head.ok_or(GitCompatStateError::UnbornHead);
2910    }
2911    if let Some(id) = state.tags.get(&object.0) {
2912        return Ok(*id);
2913    }
2914    let matching: Vec<_> = state
2915        .commits
2916        .keys()
2917        .filter(|id| id.to_hex().starts_with(&object.0))
2918        .copied()
2919        .collect();
2920    match matching.as_slice() {
2921        [id] => Ok(*id),
2922        _ => Err(GitCompatStateError::Unknown(object.0.clone())),
2923    }
2924}
2925
2926fn visible_head(state: &GitCompatState) -> Option<GitCommitId> {
2927    state
2928        .bisect
2929        .as_ref()
2930        .and_then(|bisect| bisect.current)
2931        .or_else(|| state.current().ok().and_then(|branch| branch.head))
2932}
2933
2934fn execute_bisect(
2935    state: &mut GitCompatState,
2936    branch: &GitBranch,
2937    workspace: GitTreeRef,
2938    arguments: &[String],
2939) -> Result<GitCommandOutput, GitCompatStateError> {
2940    let subcommand = arguments.first().map_or("start", String::as_str);
2941    match subcommand {
2942        "start" => {
2943            if state.bisect.is_some() {
2944                return Err(GitCompatStateError::Bisect(
2945                    "a bisect session is already active".to_owned(),
2946                ));
2947            }
2948            let bad = arguments
2949                .get(1)
2950                .map(|value| resolve_object(state, Some(&GitObjectName(value.clone()))))
2951                .transpose()?
2952                .or(branch.head)
2953                .ok_or(GitCompatStateError::UnbornHead)?;
2954            let good = arguments
2955                .get(2)
2956                .map(|value| resolve_object(state, Some(&GitObjectName(value.clone()))))
2957                .transpose()?;
2958            let bad_record = state
2959                .commits
2960                .get(&bad)
2961                .ok_or(GitCompatStateError::Invalid)?;
2962            if bad_record.workspace_tree != workspace {
2963                return Err(GitCompatStateError::Bisect(
2964                    "the working copy must match the bad commit before bisect starts".to_owned(),
2965                ));
2966            }
2967            let session = GitBisectState {
2968                original_branch: state.current_branch.clone(),
2969                original_head: branch.head.ok_or(GitCompatStateError::UnbornHead)?,
2970                good,
2971                bad,
2972                current: Some(bad),
2973                skipped: BTreeSet::new(),
2974            };
2975            advance_bisect(state, branch, session)
2976        }
2977        "good" | "bad" | "skip" => {
2978            let mut session = state.bisect.clone().ok_or_else(|| {
2979                GitCompatStateError::Bisect("start a bisect session first".to_owned())
2980            })?;
2981            let marked = arguments
2982                .get(1)
2983                .map(|value| resolve_object(state, Some(&GitObjectName(value.clone()))))
2984                .transpose()?
2985                .or(session.current)
2986                .ok_or_else(|| {
2987                    GitCompatStateError::Bisect("no current commit is selected".to_owned())
2988                })?;
2989            match subcommand {
2990                "good" => session.good = Some(marked),
2991                "bad" => session.bad = marked,
2992                "skip" => {
2993                    session.skipped.insert(marked);
2994                }
2995                _ => unreachable!(),
2996            }
2997            advance_bisect(state, branch, session)
2998        }
2999        "reset" => {
3000            let session = state.bisect.clone().ok_or_else(|| {
3001                GitCompatStateError::Bisect("no bisect session is active".to_owned())
3002            })?;
3003            let result = GitBisectResult {
3004                active: false,
3005                good: session.good,
3006                bad: Some(session.bad),
3007                current: None,
3008                remaining: 0,
3009                first_bad: None,
3010            };
3011            prepare_bisect_checkout(state, branch, session.original_head, None, result)
3012        }
3013        "log" | "visualize" => Ok(GitCommandOutput::Bisect(bisect_result(
3014            state,
3015            state.bisect.as_ref(),
3016        )?)),
3017        other => Err(GitCompatStateError::Bisect(format!(
3018            "unsupported bisect operation '{other}'"
3019        ))),
3020    }
3021}
3022
3023fn advance_bisect(
3024    state: &mut GitCompatState,
3025    branch: &GitBranch,
3026    mut session: GitBisectState,
3027) -> Result<GitCommandOutput, GitCompatStateError> {
3028    let Some(good) = session.good else {
3029        session.current = Some(session.bad);
3030        let result = bisect_result(state, Some(&session))?;
3031        state.bisect = Some(session);
3032        return Ok(GitCommandOutput::Bisect(result));
3033    };
3034    let chain = first_parent_range(state, session.bad, good)?;
3035    let candidates: Vec<_> = chain
3036        .iter()
3037        .copied()
3038        .skip(1)
3039        .take(chain.len().saturating_sub(2))
3040        .filter(|commit| !session.skipped.contains(commit))
3041        .collect();
3042    if candidates.is_empty() {
3043        session.current = None;
3044        let result = GitBisectResult {
3045            active: true,
3046            good: Some(good),
3047            bad: Some(session.bad),
3048            current: None,
3049            remaining: 0,
3050            first_bad: Some(session.bad),
3051        };
3052        state.bisect = Some(session);
3053        return Ok(GitCommandOutput::Bisect(result));
3054    }
3055    let candidate = *candidates
3056        .get(candidates.len() / 2)
3057        .ok_or_else(|| GitCompatStateError::Bisect("no bisection candidate".to_owned()))?;
3058    session.current = Some(candidate);
3059    let result = GitBisectResult {
3060        active: true,
3061        good: Some(good),
3062        bad: Some(session.bad),
3063        current: Some(candidate),
3064        remaining: u32::try_from(candidates.len()).unwrap_or(u32::MAX),
3065        first_bad: None,
3066    };
3067    prepare_bisect_checkout(state, branch, candidate, Some(session), result)
3068}
3069
3070fn prepare_bisect_checkout(
3071    state: &mut GitCompatState,
3072    branch: &GitBranch,
3073    commit: GitCommitId,
3074    replacement: Option<GitBisectState>,
3075    result: GitBisectResult,
3076) -> Result<GitCommandOutput, GitCompatStateError> {
3077    let record = state
3078        .commits
3079        .get(&commit)
3080        .ok_or(GitCompatStateError::Invalid)?;
3081    let paths = branch
3082        .tracked_paths
3083        .union(&record.tracked_paths)
3084        .cloned()
3085        .collect();
3086    Ok(prepare_transition(
3087        state,
3088        GitFilesystemAction::RestoreGeneration {
3089            tree: record.tree,
3090            paths: Some(paths),
3091        },
3092        GitPendingMutation::Bisect {
3093            state: Box::new(replacement),
3094            result,
3095        },
3096    ))
3097}
3098
3099fn first_parent_range(
3100    state: &GitCompatState,
3101    bad: GitCommitId,
3102    good: GitCommitId,
3103) -> Result<Vec<GitCommitId>, GitCompatStateError> {
3104    let mut range = Vec::new();
3105    let mut next = Some(bad);
3106    let mut seen = BTreeSet::new();
3107    while let Some(commit) = next {
3108        if !seen.insert(commit) {
3109            return Err(GitCompatStateError::Invalid);
3110        }
3111        range.push(commit);
3112        if commit == good {
3113            return Ok(range);
3114        }
3115        next = state
3116            .commits
3117            .get(&commit)
3118            .ok_or(GitCompatStateError::Invalid)?
3119            .parents
3120            .first()
3121            .copied();
3122    }
3123    Err(GitCompatStateError::Bisect(
3124        "the good commit is not a first-parent ancestor of the bad commit".to_owned(),
3125    ))
3126}
3127
3128fn bisect_result(
3129    state: &GitCompatState,
3130    session: Option<&GitBisectState>,
3131) -> Result<GitBisectResult, GitCompatStateError> {
3132    let Some(session) = session else {
3133        return Ok(GitBisectResult {
3134            active: false,
3135            good: None,
3136            bad: None,
3137            current: None,
3138            remaining: 0,
3139            first_bad: None,
3140        });
3141    };
3142    let remaining = match session.good {
3143        Some(good) => first_parent_range(state, session.bad, good)?
3144            .into_iter()
3145            .skip(1)
3146            .filter(|commit| *commit != good && !session.skipped.contains(commit))
3147            .count(),
3148        None => 0,
3149    };
3150    Ok(GitBisectResult {
3151        active: true,
3152        good: session.good,
3153        bad: Some(session.bad),
3154        current: session.current,
3155        remaining: u32::try_from(remaining).unwrap_or(u32::MAX),
3156        first_bad: session.good.filter(|_| remaining == 0).map(|_| session.bad),
3157    })
3158}
3159
3160fn commit_generation_ref(commit: &GitCommit, _fallback: WorkspaceId) -> GitTreeRef {
3161    commit.tree
3162}
3163
3164fn walk_commits(
3165    state: &GitCompatState,
3166    mut next: Option<GitCommitId>,
3167    maximum: u32,
3168) -> Vec<GitCommit> {
3169    let mut commits = Vec::new();
3170    while let Some(id) = next {
3171        if commits.len() >= usize::try_from(maximum).unwrap_or(usize::MAX) {
3172            break;
3173        }
3174        let Some(commit) = state.commits.get(&id) else {
3175            break;
3176        };
3177        commits.push(commit.clone());
3178        next = commit.parents.first().copied();
3179    }
3180    commits
3181}
3182
3183fn expand_pathspecs(paths: &[String], tracked_paths: &BTreeSet<String>) -> Vec<String> {
3184    let mut expanded = BTreeSet::new();
3185    for path in paths {
3186        let path = path.trim_matches('/');
3187        let mut matched = false;
3188        for tracked in tracked_paths {
3189            if tracked == path || is_path_below(tracked, path) {
3190                expanded.insert(tracked.clone());
3191                matched = true;
3192            }
3193        }
3194        if !matched && !path.is_empty() {
3195            expanded.insert(path.to_owned());
3196        }
3197    }
3198    expanded.into_iter().collect()
3199}
3200
3201#[allow(clippy::too_many_lines)]
3202#[allow(
3203    clippy::cognitive_complexity,
3204    reason = "the argv compatibility matrix is intentionally exhaustive and centralized"
3205)]
3206fn parse_argv<E: std::error::Error + 'static>(
3207    argv: &[String],
3208    default_author: &str,
3209    now_seconds: i64,
3210) -> Result<GitCommand, GitCompatError<E>> {
3211    reject_shell_composition(argv)?;
3212    let Some(command) = argv.first().map(String::as_str) else {
3213        return Err(GitCompatError::InvalidCommand("missing command".to_owned()));
3214    };
3215    let args = argv.get(1..).unwrap_or_default();
3216    match command {
3217        "status" => {
3218            require_only_options(command, args, &["--short", "--porcelain", "--porcelain=v1"])?;
3219            Ok(GitCommand::Status)
3220        }
3221        "diff" => {
3222            require_only_options(command, args, &["--cached", "--staged"])?;
3223            Ok(GitCommand::Diff {
3224                cached: args
3225                    .iter()
3226                    .any(|arg| arg == "--cached" || arg == "--staged"),
3227            })
3228        }
3229        "log" => Ok(GitCommand::Log {
3230            maximum: parse_log_maximum(args)?,
3231        }),
3232        "show" => Ok(GitCommand::Show {
3233            object: optional_single_value(command, args)?.map(GitObjectName),
3234        }),
3235        "add" => {
3236            if args.is_empty() {
3237                return Err(GitCompatError::InvalidCommand(
3238                    "add requires a path or -A/--all".to_owned(),
3239                ));
3240            }
3241            let mut options_ended = false;
3242            let mut stages_without_paths = false;
3243            let mut paths = Vec::new();
3244            for arg in args {
3245                if !options_ended && arg == "--" {
3246                    options_ended = true;
3247                } else if !options_ended && arg.starts_with('-') {
3248                    if matches!(arg.as_str(), "-A" | "--all" | "-u" | "--update") {
3249                        stages_without_paths = true;
3250                    } else {
3251                        return unsupported_option(command, arg);
3252                    }
3253                } else {
3254                    paths.push(arg.clone());
3255                }
3256            }
3257            if paths.is_empty() && !stages_without_paths {
3258                return Err(GitCompatError::InvalidCommand(
3259                    "add requires a path or -A/--all".to_owned(),
3260                ));
3261            }
3262            Ok(GitCommand::Add { paths })
3263        }
3264        "commit" => {
3265            let mut message = None;
3266            let mut author = default_author.to_owned();
3267            let mut index = 0;
3268            while index < args.len() {
3269                let Some(argument) = args.get(index) else {
3270                    break;
3271                };
3272                let (name, inline) = split_long_option(argument);
3273                if name == "-m" || name == "--message" {
3274                    let value = inline
3275                        .map(str::to_owned)
3276                        .or_else(|| args.get(index + 1).cloned())
3277                        .ok_or_else(|| {
3278                            GitCompatError::InvalidCommand("commit requires a message after -m".to_owned())
3279                        })?;
3280                    if message.replace(value).is_some() {
3281                        return Err(GitCompatError::InvalidCommand(
3282                            "multiple commit messages are not supported".to_owned(),
3283                        ));
3284                    }
3285                    index += usize::from(inline.is_none());
3286                } else if name == "--author" {
3287                    author = inline
3288                        .map(str::to_owned)
3289                        .or_else(|| args.get(index + 1).cloned())
3290                        .ok_or_else(|| {
3291                            GitCompatError::InvalidCommand(
3292                                "commit requires an author after --author".to_owned(),
3293                            )
3294                        })?;
3295                    index += usize::from(inline.is_none());
3296                } else {
3297                    return unsupported_option(command, argument);
3298                }
3299                index += 1;
3300            }
3301            let message = message.ok_or_else(|| {
3302                GitCompatError::InvalidCommand("commit requires -m".to_owned())
3303            })?;
3304            Ok(GitCommand::Commit {
3305                message,
3306                author,
3307                authored_at_seconds: now_seconds,
3308            })
3309        }
3310        "branch" => Ok(GitCommand::Branch {
3311            create: optional_single_value(command, args)?,
3312        }),
3313        "switch" => {
3314            let (create, values) = positional_with_options(command, args, &["-c"])?;
3315            let branch = exactly_one(command, &values)?;
3316            Ok(GitCommand::Switch { branch, create })
3317        }
3318        "checkout" => {
3319            if let Some(separator) = args.iter().position(|argument| argument == "--") {
3320                if separator > 1 {
3321                    return Err(GitCompatError::InvalidCommand(
3322                        "checkout path restoration accepts at most one source object".to_owned(),
3323                    ));
3324                }
3325                let paths = args.get(separator + 1..).unwrap_or_default().to_vec();
3326                if paths.is_empty() {
3327                    return Err(GitCompatError::InvalidCommand(
3328                        "checkout -- requires at least one path".to_owned(),
3329                    ));
3330                }
3331                return Ok(GitCommand::Restore {
3332                    source: args.first().map(|source| GitObjectName(source.clone())),
3333                    paths,
3334                });
3335            }
3336            let (create, values) = positional_with_options(command, args, &["-b"])?;
3337            let branch = exactly_one(command, &values)?;
3338            Ok(GitCommand::Switch { branch, create })
3339        }
3340        "restore" => {
3341            let mut source = None;
3342            let mut paths = Vec::new();
3343            let mut options_ended = false;
3344            let mut index = 0;
3345            while index < args.len() {
3346                let Some(argument) = args.get(index) else {
3347                    break;
3348                };
3349                if !options_ended && argument == "--" {
3350                    options_ended = true;
3351                } else if !options_ended {
3352                    let (name, inline) = split_long_option(argument);
3353                    if name == "--source" {
3354                        let value = inline
3355                            .map(str::to_owned)
3356                            .or_else(|| args.get(index + 1).cloned())
3357                            .ok_or_else(|| {
3358                                GitCompatError::InvalidCommand(
3359                                    "restore requires an object after --source".to_owned(),
3360                                )
3361                            })?;
3362                        if source.replace(GitObjectName(value)).is_some() {
3363                            return Err(GitCompatError::InvalidCommand(
3364                                "restore accepts only one --source".to_owned(),
3365                            ));
3366                        }
3367                        index += usize::from(inline.is_none());
3368                    } else if argument.starts_with('-') {
3369                        return unsupported_option(command, argument);
3370                    } else {
3371                        paths.push(argument.clone());
3372                    }
3373                } else {
3374                    paths.push(argument.clone());
3375                }
3376                index += 1;
3377            }
3378            if paths.is_empty() {
3379                return Err(GitCompatError::InvalidCommand(
3380                    "restore requires at least one path".to_owned(),
3381                ));
3382            }
3383            Ok(GitCommand::Restore { source, paths })
3384        }
3385        "reset" => {
3386            require_options(command, args, &["--hard", "--soft", "--mixed"])?;
3387            let modes = args
3388                .iter()
3389                .filter(|arg| matches!(arg.as_str(), "--hard" | "--soft" | "--mixed"))
3390                .count();
3391            if modes > 1 {
3392                return Err(GitCompatError::InvalidCommand(
3393                    "reset accepts only one mode".to_owned(),
3394                ));
3395            }
3396            let mode = if args.iter().any(|arg| arg == "--hard") {
3397                GitResetMode::Hard
3398            } else if args.iter().any(|arg| arg == "--soft") {
3399                GitResetMode::Soft
3400            } else {
3401                GitResetMode::Mixed
3402            };
3403            let targets = args
3404                .iter()
3405                .filter(|arg| !arg.starts_with('-'))
3406                .cloned()
3407                .collect::<Vec<_>>();
3408            let target = match targets.as_slice() {
3409                [] => "HEAD".to_owned(),
3410                [target] => target.clone(),
3411                _ => return Err(GitCompatError::InvalidCommand(
3412                    "reset accepts only one target; path reset is not supported".to_owned(),
3413                )),
3414            };
3415            Ok(GitCommand::Reset {
3416                target: GitObjectName(target),
3417                mode,
3418            })
3419        }
3420        "merge" if matches!(args, [operation] if operation == "--continue") => {
3421            Ok(GitCommand::MergeContinue)
3422        }
3423        "merge" if matches!(args, [operation] if operation == "--abort") => {
3424            Ok(GitCommand::MergeAbort)
3425        }
3426        "merge" | "rebase" => {
3427            let branch = exactly_one(command, args)?;
3428            if command == "merge" {
3429                Ok(GitCommand::Merge { branch })
3430            } else {
3431                Ok(GitCommand::Rebase { branch })
3432            }
3433        }
3434        "cherry-pick" | "revert" => {
3435            let object = GitObjectName(exactly_one(command, args)?);
3436            if command == "cherry-pick" {
3437                Ok(GitCommand::CherryPick { object })
3438            } else {
3439                Ok(GitCommand::Revert { object })
3440            }
3441        }
3442        "stash" => match args {
3443            [] => Ok(GitCommand::StashPush),
3444            [operation] if operation == "push" => Ok(GitCommand::StashPush),
3445            [operation] if operation == "pop" => Ok(GitCommand::StashPop),
3446            [other] => Err(GitCompatError::InvalidCommand(format!(
3447                "unsupported stash operation '{other}'"
3448            ))),
3449            _ => Err(GitCompatError::InvalidCommand(
3450                "stash accepts only push or pop".to_owned(),
3451            )),
3452        },
3453        "tag" => {
3454            let delete = args.iter().any(|arg| arg == "-d" || arg == "--delete");
3455            require_options(command, args, &["-d", "--delete"])?;
3456            let values: Vec<_> = args.iter().filter(|arg| !arg.starts_with('-')).cloned().collect();
3457            if values.len() > 2 || (delete && values.len() != 1) {
3458                return Err(GitCompatError::InvalidCommand(
3459                    "tag accepts [name [target]] or --delete name".to_owned(),
3460                ));
3461            }
3462            Ok(GitCommand::Tag {
3463                name: values.first().cloned(),
3464                target: values.get(1).cloned().map(GitObjectName),
3465                delete,
3466            })
3467        }
3468        "blame" => Ok(GitCommand::Blame {
3469            path: exactly_one(command, args)?,
3470        }),
3471        "grep" => match args {
3472            [pattern] => Ok(GitCommand::Grep {
3473                pattern: pattern.clone(),
3474                path: None,
3475            }),
3476            [pattern, path] => Ok(GitCommand::Grep {
3477                pattern: pattern.clone(),
3478                path: Some(path.clone()),
3479            }),
3480            _ => Err(GitCompatError::InvalidCommand(
3481                "grep requires a pattern and at most one path".to_owned(),
3482            )),
3483        },
3484        "clean" => {
3485            require_only_options(
3486                command,
3487                args,
3488                &["-n", "--dry-run", "-f", "--force"],
3489            )?;
3490            let dry_run = args
3491                .iter()
3492                .any(|arg| matches!(arg.as_str(), "-n" | "--dry-run"));
3493            let force = args
3494                .iter()
3495                .any(|arg| matches!(arg.as_str(), "-f" | "--force"));
3496            if !dry_run && !force {
3497                return Err(GitCompatError::InvalidCommand(
3498                    "clean requires -f or --force unless --dry-run is used".to_owned(),
3499                ));
3500            }
3501            Ok(GitCommand::Clean { dry_run })
3502        }
3503        "archive" => Ok(GitCommand::Archive {
3504            object: optional_single_value(command, args)?.map(GitObjectName),
3505        }),
3506        "apply" => Err(GitCompatError::Unsupported {
3507            command: command.to_owned(),
3508            reason: "argv cannot safely turn a patch filename into patch bytes; load the file and use the typed Apply command".to_owned(),
3509        }),
3510        "bisect" => Ok(GitCommand::Bisect {
3511            arguments: args.to_vec(),
3512        }),
3513        "rev-parse" => match args {
3514            [argument] if matches!(argument.as_str(), "--is-inside-work-tree" | "HEAD") => {
3515                Ok(GitCommand::RevParse {
3516                    argument: argument.clone(),
3517                })
3518            }
3519            [option, head]
3520                if head == "HEAD"
3521                    && matches!(
3522                        option.as_str(),
3523                        "--abbrev-ref" | "--symbolic-full-name"
3524                    ) => Ok(GitCommand::RevParse {
3525                argument: format!("{option} HEAD"),
3526            }),
3527            [object] if !object.starts_with('-') => Ok(GitCommand::RevParse {
3528                argument: object.clone(),
3529            }),
3530            _ => Err(GitCompatError::InvalidCommand(
3531                "rev-parse supports HEAD, one object, --is-inside-work-tree, --abbrev-ref HEAD, or --symbolic-full-name HEAD".to_owned(),
3532            )),
3533        },
3534        "symbolic-ref" => {
3535            let short = args.iter().any(|argument| argument == "--short");
3536            require_options(command, args, &["--short"])?;
3537            let values = args
3538                .iter()
3539                .filter(|argument| !argument.starts_with('-'))
3540                .collect::<Vec<_>>();
3541            if values.as_slice() != [&"HEAD"] {
3542                return Err(GitCompatError::InvalidCommand(
3543                    "symbolic-ref supports only HEAD".to_owned(),
3544                ));
3545            }
3546            Ok(GitCommand::SymbolicRef { short })
3547        }
3548        "merge-base" => match args {
3549            [left, right] => Ok(GitCommand::MergeBase {
3550                left: GitObjectName(left.clone()),
3551                right: GitObjectName(right.clone()),
3552            }),
3553            _ => Err(GitCompatError::InvalidCommand(
3554                "merge-base requires exactly two objects".to_owned(),
3555            )),
3556        },
3557        "ls-files" => {
3558            require_only_options(command, args, &["--cached"])?;
3559            Ok(GitCommand::LsFiles)
3560        }
3561        "check-ignore" => {
3562            let mut paths = Vec::new();
3563            for argument in args {
3564                if argument == "--" {
3565                    continue;
3566                }
3567                if argument.starts_with('-') {
3568                    return unsupported_option(command, argument);
3569                }
3570                paths.push(argument.clone());
3571            }
3572            if paths.is_empty() {
3573                return Err(GitCompatError::InvalidCommand(
3574                    "check-ignore requires at least one path".to_owned(),
3575                ));
3576            }
3577            Ok(GitCommand::CheckIgnore { paths })
3578        }
3579        "clone" | "fetch" | "pull" | "push" | "remote" | "gc" | "repack" | "cat-file"
3580        | "hash-object" | "init" | "fsck" | "prune" | "pack-objects" | "index-pack"
3581        | "receive-pack" | "upload-pack" | "read-tree" | "write-tree" | "commit-tree"
3582        | "update-index" | "worktree" | "submodule" => Err(GitCompatError::Unsupported {
3583            command: command.to_owned(),
3584            reason: "the compatibility layer has no Git object database or transport".to_owned(),
3585        }),
3586        other => Err(GitCompatError::InvalidCommand(format!(
3587            "unknown command '{other}'"
3588        ))),
3589    }
3590}
3591
3592fn reject_shell_composition<E: std::error::Error + 'static>(
3593    argv: &[String],
3594) -> Result<(), GitCompatError<E>> {
3595    if let Some(token) = argv.iter().find(|token| {
3596        matches!(token.as_str(), "&&" | "||" | ";" | "|" | "&")
3597            || token.starts_with('>')
3598            || token.starts_with('<')
3599            || token.contains('`')
3600            || token.contains("$(")
3601    }) {
3602        return Err(GitCompatError::Unsupported {
3603            command: token.clone(),
3604            reason: "shell composition is outside the command-level compatibility façade"
3605                .to_owned(),
3606        });
3607    }
3608    Ok(())
3609}
3610
3611fn require_options<E: std::error::Error + 'static>(
3612    command: &str,
3613    args: &[String],
3614    allowed: &[&str],
3615) -> Result<(), GitCompatError<E>> {
3616    if let Some(argument) = args
3617        .iter()
3618        .find(|argument| argument.starts_with('-') && !allowed.contains(&argument.as_str()))
3619    {
3620        return unsupported_option(command, argument);
3621    }
3622    Ok(())
3623}
3624
3625fn require_only_options<E: std::error::Error + 'static>(
3626    command: &str,
3627    args: &[String],
3628    allowed: &[&str],
3629) -> Result<(), GitCompatError<E>> {
3630    require_options(command, args, allowed)?;
3631    if args.iter().any(|argument| !argument.starts_with('-')) {
3632        return Err(GitCompatError::InvalidCommand(format!(
3633            "{command} does not accept positional arguments"
3634        )));
3635    }
3636    Ok(())
3637}
3638
3639fn unsupported_option<T, E: std::error::Error + 'static>(
3640    command: &str,
3641    argument: &str,
3642) -> Result<T, GitCompatError<E>> {
3643    Err(GitCompatError::Unsupported {
3644        command: command.to_owned(),
3645        reason: format!("option '{argument}' cannot be represented by the compatibility façade"),
3646    })
3647}
3648
3649fn optional_single_value<E: std::error::Error + 'static>(
3650    command: &str,
3651    args: &[String],
3652) -> Result<Option<String>, GitCompatError<E>> {
3653    match args {
3654        [] => Ok(None),
3655        [value] if !value.starts_with('-') => Ok(Some(value.clone())),
3656        [option] => unsupported_option(command, option),
3657        _ => Err(GitCompatError::InvalidCommand(format!(
3658            "{command} accepts at most one argument"
3659        ))),
3660    }
3661}
3662
3663fn exactly_one<E: std::error::Error + 'static>(
3664    command: &str,
3665    values: &[String],
3666) -> Result<String, GitCompatError<E>> {
3667    match values {
3668        [value] if !value.starts_with('-') => Ok(value.clone()),
3669        [option] => unsupported_option(command, option),
3670        _ => Err(GitCompatError::InvalidCommand(format!(
3671            "{command} requires exactly one argument"
3672        ))),
3673    }
3674}
3675
3676fn positional_with_options<E: std::error::Error + 'static>(
3677    command: &str,
3678    args: &[String],
3679    allowed: &[&str],
3680) -> Result<(bool, Vec<String>), GitCompatError<E>> {
3681    require_options(command, args, allowed)?;
3682    Ok((
3683        args.iter()
3684            .any(|argument| allowed.contains(&argument.as_str())),
3685        args.iter()
3686            .filter(|argument| !allowed.contains(&argument.as_str()))
3687            .cloned()
3688            .collect(),
3689    ))
3690}
3691
3692fn split_long_option(argument: &str) -> (&str, Option<&str>) {
3693    argument
3694        .split_once('=')
3695        .map_or((argument, None), |(name, value)| (name, Some(value)))
3696}
3697
3698fn parse_log_maximum<E: std::error::Error + 'static>(
3699    args: &[String],
3700) -> Result<u32, GitCompatError<E>> {
3701    if args.is_empty() {
3702        return Ok(100);
3703    }
3704    let value = match args {
3705        [option, value] if option == "-n" || option == "--max-count" => value.as_str(),
3706        [option] if option.starts_with("--max-count=") => option
3707            .split_once('=')
3708            .map(|(_, value)| value)
3709            .unwrap_or_default(),
3710        [option] if option.len() > 1 => option
3711            .strip_prefix('-')
3712            .ok_or_else(|| GitCompatError::InvalidCommand("invalid log count".to_owned()))?,
3713        [option] => return unsupported_option("log", option),
3714        _ => {
3715            return Err(GitCompatError::InvalidCommand(
3716                "log accepts only -n <count>, -<count>, or --max-count=<count>".to_owned(),
3717            ));
3718        }
3719    };
3720    value.parse::<u32>().map_err(|_| {
3721        GitCompatError::InvalidCommand("log count must be an unsigned 32-bit integer".to_owned())
3722    })
3723}
3724
3725/// Git-compatible ignore policy used by commit and parent-join capture.
3726///
3727/// Already tracked paths always remain eligible. Rules are evaluated in order;
3728/// later matches win and `!` negates an ignore rule. The matcher intentionally
3729/// covers the portable Git subset (`*`, `?`, directory suffixes, and anchored
3730/// paths) without consulting system Git.
3731#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
3732pub struct GitIgnorePolicy {
3733    rules: Vec<GitIgnoreRule>,
3734}
3735
3736/// Exact ignore-aware snapshot produced for one compatibility commit.
3737pub struct GitCapturedGeneration<A, O> {
3738    /// Immutable generation containing only compatibility-eligible paths.
3739    pub generation: Generation<A, O>,
3740    /// Initial generation of the capture workspace, when capture forked.
3741    initial_generation: Option<crate::GenerationId>,
3742    /// Eligible non-directory paths represented by the snapshot.
3743    pub tracked_paths: BTreeSet<String>,
3744    fork_parent: Option<Generation<A, O>>,
3745}
3746
3747impl<A: AsyncAuthorityStore, O: AsyncObjectStore> GitCapturedGeneration<A, O> {
3748    /// Issues a capture proof only after the SDK has durably recorded its
3749    /// authenticated direct-parent lineage. A caller cannot publish a proof
3750    /// for a fork that recovery cannot reopen.
3751    pub async fn authenticated_proof<S: crate::WorkspaceLineageStore>(
3752        &self,
3753        operation_id: OperationId,
3754        graph: &crate::WorkspaceGraph<S>,
3755    ) -> Result<Option<GitCaptureProof>, GitCaptureAuthenticationError<S::Error>> {
3756        let proof = self.verified_proof(operation_id).await?;
3757        if let (Some(parent), Some(initial_generation)) =
3758            (&self.fork_parent, self.initial_generation)
3759        {
3760            graph
3761                .register_existing_child_at_generation(
3762                    &parent.workspace(),
3763                    &self.generation.workspace(),
3764                    parent.id(),
3765                    initial_generation,
3766                )
3767                .await?;
3768        }
3769        Ok(proof)
3770    }
3771
3772    /// Authenticates this filtered snapshot against its original SDK fork.
3773    /// An unfiltered tree has no proof and retains its original workspace.
3774    async fn verified_proof(
3775        &self,
3776        operation_id: OperationId,
3777    ) -> Result<Option<GitCaptureProof>, GitCaptureError> {
3778        let (parent, initial_generation) = match (&self.fork_parent, self.initial_generation) {
3779            (Some(parent), Some(initial_generation)) => (parent, initial_generation),
3780            (None, None) => return Ok(None),
3781            _ => return Err(GitCaptureError::Conflict),
3782        };
3783        let child = self.generation.workspace();
3784        let expected_suffix = format!("-{}", hex::encode(operation_id.into_bytes()));
3785        if !child.name().as_str().starts_with("git-capture-")
3786            || !child.name().as_str().ends_with(&expected_suffix)
3787        {
3788            return Err(GitCaptureError::Conflict);
3789        }
3790        let expected_child = parent
3791            .workspace()
3792            .fork_workspace_id(child.name().as_str())
3793            .map_err(WorkspaceError::from)?;
3794        if child.id() != expected_child {
3795            return Err(GitCaptureError::Conflict);
3796        }
3797        let initial = child.generation(initial_generation).await?;
3798        if initial.parents().await?.as_slice() != [parent.id()] {
3799            return Err(GitCaptureError::Conflict);
3800        }
3801        let result_parents = self.generation.parents().await?;
3802        if self.generation.id() != initial_generation
3803            && result_parents.as_slice() != [initial_generation]
3804        {
3805            return Err(GitCaptureError::Conflict);
3806        }
3807        Ok(Some(GitCaptureProof {
3808            fork_parent: GitTreeRef::exact(parent.workspace_id(), parent.id()),
3809            initial_generation,
3810            operation_id,
3811        }))
3812    }
3813}
3814
3815/// Portable namespace fact used by Git-shaped inspection commands.
3816#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
3817pub struct GitTreeEntry {
3818    /// Slash-separated path relative to the compatibility root.
3819    pub path: String,
3820    /// Stable textual filesystem kind.
3821    pub kind: String,
3822}
3823
3824/// One literal grep match in an authenticated generation.
3825#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
3826pub struct GitGrepMatch {
3827    /// Slash-separated path relative to the compatibility root.
3828    pub path: String,
3829    /// One-based logical line number.
3830    pub line: u64,
3831    /// Complete UTF-8 line without its terminator.
3832    pub text: String,
3833}
3834
3835/// Bounded machine-readable grep result.
3836#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
3837pub struct GitGrepResult {
3838    /// Literal matching lines in stable path/line order.
3839    pub matches: Vec<GitGrepMatch>,
3840    /// More paths, bytes, or matches existed beyond the supplied bounds.
3841    pub truncated: bool,
3842}
3843
3844/// Fail-closed unified-patch application error.
3845#[derive(Debug, Error)]
3846pub enum GitPatchError {
3847    /// Workspace access or publication failed.
3848    #[error(transparent)]
3849    Workspace(#[from] WorkspaceError),
3850    /// Patch bytes are not UTF-8 unified text.
3851    #[error("Git patch is not UTF-8 unified text")]
3852    NonText,
3853    /// Patch syntax is incomplete or unsupported.
3854    #[error("invalid Git unified patch: {0}")]
3855    Invalid(String),
3856    /// A hunk does not match the authenticated working generation.
3857    #[error("Git patch hunk does not apply to '{path}' at source line {line}")]
3858    Conflict {
3859        /// Portable path whose preimage did not match.
3860        path: String,
3861        /// One-based source line from the hunk header.
3862        line: usize,
3863    },
3864}
3865
3866/// One line attributed across explicit compatibility history.
3867#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
3868pub struct GitBlameLine {
3869    /// Compatibility commit that last changed this line position.
3870    pub commit: GitCommitId,
3871    /// One-based line number in the selected version.
3872    pub line: u64,
3873    /// Complete UTF-8 line without its terminator.
3874    pub text: String,
3875}
3876
3877/// Walks an authenticated tree in stable portable path order.
3878pub async fn walk_git_tree<A: AsyncAuthorityStore, O: AsyncObjectStore>(
3879    generation: &Generation<A, O>,
3880    root: Option<&str>,
3881    maximum_entries: u32,
3882) -> Result<Vec<GitTreeEntry>, WorkspaceError> {
3883    if maximum_entries == 0 {
3884        return Ok(Vec::new());
3885    }
3886    let root = root.unwrap_or("").trim_matches('/');
3887    let start = if root.is_empty() {
3888        "/".to_owned()
3889    } else {
3890        format!("/{root}")
3891    };
3892    if !root.is_empty() {
3893        let stat = generation.stat(&start).await?;
3894        if stat.kind != FileKind::Directory {
3895            return Ok(vec![GitTreeEntry {
3896                path: root.to_owned(),
3897                kind: git_kind(stat.kind).to_owned(),
3898            }]);
3899        }
3900    }
3901    let maximum = usize::try_from(maximum_entries).unwrap_or(usize::MAX);
3902    let mut entries = Vec::new();
3903    let mut pending = vec![start];
3904    while let Some(directory) = pending.pop() {
3905        let mut after = None;
3906        loop {
3907            let page = generation
3908                .list_directory(&directory, after.as_ref(), 1_024)
3909                .await?;
3910            for entry in &page.entries {
3911                let name = entry
3912                    .name
3913                    .unicode_text()
3914                    .ok_or_else(|| WorkspaceError::path("non-portable Git path"))?;
3915                let parent = directory.trim_start_matches('/');
3916                let path = if parent.is_empty() {
3917                    name.to_string()
3918                } else {
3919                    format!("{parent}/{name}")
3920                };
3921                entries.push(GitTreeEntry {
3922                    path: path.clone(),
3923                    kind: git_kind(entry.kind).to_owned(),
3924                });
3925                if entries.len() >= maximum {
3926                    entries.sort_by(|left, right| left.path.cmp(&right.path));
3927                    return Ok(entries);
3928                }
3929                if entry.kind == FileKind::Directory {
3930                    pending.push(format!("/{path}"));
3931                }
3932            }
3933            if !page.has_more {
3934                break;
3935            }
3936            after = page.entries.last().map(|entry| entry.name.clone());
3937        }
3938    }
3939    entries.sort_by(|left, right| left.path.cmp(&right.path));
3940    Ok(entries)
3941}
3942
3943/// Searches UTF-8 regular files without consulting the host filesystem.
3944pub async fn grep_git_generation<A: AsyncAuthorityStore, O: AsyncObjectStore>(
3945    generation: &Generation<A, O>,
3946    pattern: &str,
3947    root: Option<&str>,
3948    maximum_entries: u32,
3949    maximum_file_bytes: u64,
3950    maximum_matches: u32,
3951) -> Result<GitGrepResult, WorkspaceError> {
3952    if maximum_entries == 0 {
3953        return Ok(GitGrepResult {
3954            matches: Vec::new(),
3955            truncated: true,
3956        });
3957    }
3958    let cancellation = CancellationToken::new();
3959    let checkout = generation
3960        .workspace
3961        .engine_checkout(
3962            GenerationSelector::Exact(generation.id()),
3963            CheckoutMode::read_only_pinned(),
3964        )
3965        .await?;
3966    let reader = checkout.pinned_reader().map_err(WorkspaceError::engine)?;
3967    let limits = checkout.volume_config().limits;
3968    let root = root.unwrap_or("/");
3969    let root_path = customer_path(
3970        &format!("/{}", root.trim_start_matches('/')),
3971        checkout.volume_config(),
3972    )?;
3973    let root_display = root.trim_matches('/').to_owned();
3974    let maximum_entries = usize::try_from(maximum_entries).unwrap_or(usize::MAX);
3975    let (regular, mut truncated) = resolved_git_grep_files(
3976        &reader,
3977        root_display,
3978        root_path,
3979        limits,
3980        maximum_entries,
3981        &cancellation,
3982    )
3983    .await?;
3984    let mut matches = Vec::new();
3985    let maximum_matches = usize::try_from(maximum_matches).unwrap_or(usize::MAX);
3986    for batch in regular.chunks(GREP_READ_CONCURRENCY) {
3987        let mut requests = Vec::with_capacity(batch.len());
3988        let mut selected = Vec::with_capacity(batch.len());
3989        for (path, file) in batch {
3990            if file.description().logical_bytes > maximum_file_bytes {
3991                truncated = true;
3992                continue;
3993            }
3994            requests.push(ResolvedFileRangeReadRequest {
3995                file,
3996                range: ByteRange {
3997                    offset: 0,
3998                    length: file.description().logical_bytes,
3999                },
4000            });
4001            selected.push(path);
4002        }
4003        if requests.is_empty() {
4004            continue;
4005        }
4006        let reads = reader
4007            .read_resolved_ranges(
4008                &requests,
4009                GREP_READ_CONCURRENCY,
4010                WorkBudget::UNBOUNDED,
4011                &cancellation,
4012            )
4013            .await
4014            .map_err(WorkspaceError::engine)?
4015            .value;
4016        for (path, read) in selected.into_iter().zip(reads) {
4017            let bytes = read.bytes;
4018            let Ok(text) = std::str::from_utf8(&bytes) else {
4019                continue;
4020            };
4021            for (index, line) in text.lines().enumerate() {
4022                if line.contains(pattern) {
4023                    if matches.len() >= maximum_matches {
4024                        return Ok(GitGrepResult {
4025                            matches,
4026                            truncated: true,
4027                        });
4028                    }
4029                    matches.push(GitGrepMatch {
4030                        path: path.clone(),
4031                        line: u64::try_from(index).unwrap_or(u64::MAX).saturating_add(1),
4032                        text: line.to_owned(),
4033                    });
4034                }
4035            }
4036        }
4037    }
4038    Ok(GitGrepResult { matches, truncated })
4039}
4040
4041/// Attributes UTF-8 lines using explicit oldest-to-newest compatibility
4042/// snapshots. This deliberately follows exact line positions; rename and
4043/// heuristic similarity detection remain presentation concerns.
4044pub async fn blame_git_generations<A: AsyncAuthorityStore, O: AsyncObjectStore>(
4045    history_newest_first: &[(GitCommit, Generation<A, O>)],
4046    path: &str,
4047    maximum_file_bytes: u64,
4048) -> Result<Vec<GitBlameLine>, WorkspaceError> {
4049    let relative = path.trim_start_matches('/');
4050    let absolute = format!("/{relative}");
4051    let mut current = Vec::<String>::new();
4052    let mut owners = Vec::<GitCommitId>::new();
4053    for (commit, generation) in history_newest_first.iter().rev() {
4054        let next = if commit.tracked_paths.contains(relative) {
4055            let bytes = generation.read(&absolute, maximum_file_bytes).await?;
4056            std::str::from_utf8(&bytes)
4057                .map_err(|_| WorkspaceError::path("Git blame requires UTF-8 text"))?
4058                .lines()
4059                .map(str::to_owned)
4060                .collect::<Vec<_>>()
4061        } else {
4062            Vec::new()
4063        };
4064        let mut next_owners = Vec::with_capacity(next.len());
4065        for (index, line) in next.iter().enumerate() {
4066            next_owners.push(if current.get(index) == Some(line) {
4067                owners.get(index).copied().unwrap_or(commit.id)
4068            } else {
4069                commit.id
4070            });
4071        }
4072        current = next;
4073        owners = next_owners;
4074    }
4075    Ok(current
4076        .into_iter()
4077        .zip(owners)
4078        .enumerate()
4079        .map(|(index, (text, commit))| GitBlameLine {
4080            commit,
4081            line: u64::try_from(index).unwrap_or(u64::MAX).saturating_add(1),
4082            text,
4083        })
4084        .collect())
4085}
4086
4087#[derive(Clone, Debug)]
4088struct UnifiedPatch {
4089    old_path: Option<String>,
4090    new_path: Option<String>,
4091    hunks: Vec<UnifiedHunk>,
4092}
4093
4094#[derive(Clone, Debug)]
4095struct UnifiedHunk {
4096    old_start: usize,
4097    lines: Vec<String>,
4098}
4099
4100/// Applies a bounded UTF-8 unified patch as one atomic workspace transaction.
4101/// System Git, a Git object database, and host-path writes are never involved.
4102pub async fn apply_git_patch<A: AsyncAuthorityStore, O: AsyncObjectStore>(
4103    workspace: &Workspace<A, O>,
4104    patch: &[u8],
4105    idempotency_key: IdempotencyKey,
4106) -> Result<TransactionCommit<A, O>, GitPatchError> {
4107    apply_git_patch_with_permit(
4108        workspace,
4109        patch,
4110        idempotency_key,
4111        crate::PublicationPermit::Unrestricted,
4112    )
4113    .await
4114}
4115
4116/// Applies a bounded UTF-8 patch under one writer permit.
4117pub async fn apply_git_patch_with_permit<A: AsyncAuthorityStore, O: AsyncObjectStore>(
4118    workspace: &Workspace<A, O>,
4119    patch: &[u8],
4120    idempotency_key: IdempotencyKey,
4121    permit: crate::PublicationPermit,
4122) -> Result<TransactionCommit<A, O>, GitPatchError> {
4123    if let Some(generation) = workspace.operation_generation(idempotency_key).await? {
4124        return Ok(TransactionCommit::AlreadyCommitted(generation));
4125    }
4126    let patches = parse_unified_patch(patch)?;
4127    let head = workspace.head().await?;
4128    let mut transaction = workspace.begin_transaction(idempotency_key).await?;
4129    for patch in patches {
4130        let path = patch
4131            .new_path
4132            .as_ref()
4133            .or(patch.old_path.as_ref())
4134            .ok_or_else(|| GitPatchError::Invalid("patch has no path".to_owned()))?;
4135        let original = if patch.old_path.is_none() {
4136            String::new()
4137        } else {
4138            let bytes = head
4139                .read(&format!("/{path}"), MAXIMUM_PATCH_FILE_BYTES)
4140                .await?;
4141            String::from_utf8(bytes.to_vec()).map_err(|_| GitPatchError::NonText)?
4142        };
4143        let replacement = apply_unified_hunks(path, &original, &patch.hunks)?;
4144        if patch.new_path.is_none() {
4145            if !replacement.is_empty() {
4146                return Err(GitPatchError::Invalid(
4147                    "deleted-file patch leaves content".to_owned(),
4148                ));
4149            }
4150            transaction.remove(&format!("/{path}")).await?;
4151            continue;
4152        }
4153        if let Some((parent, _)) = path.rsplit_once('/') {
4154            transaction.create_dir_all(&format!("/{parent}")).await?;
4155        }
4156        transaction
4157            .write_text(&format!("/{path}"), &replacement)
4158            .await?;
4159    }
4160    transaction
4161        .commit_with_permit(permit)
4162        .await
4163        .map_err(Into::into)
4164}
4165
4166#[allow(
4167    clippy::indexing_slicing,
4168    reason = "every patch line index is bounded by the surrounding length checks"
4169)]
4170fn parse_unified_patch(bytes: &[u8]) -> Result<Vec<UnifiedPatch>, GitPatchError> {
4171    let text = std::str::from_utf8(bytes).map_err(|_| GitPatchError::NonText)?;
4172    if text.contains("GIT binary patch") {
4173        return Err(GitPatchError::Invalid(
4174            "binary patches require a typed binary mutation".to_owned(),
4175        ));
4176    }
4177    let lines: Vec<_> = text.split_inclusive('\n').collect();
4178    let mut patches = Vec::new();
4179    let mut index = 0;
4180    while index < lines.len() {
4181        if !lines[index].starts_with("--- ") {
4182            index += 1;
4183            continue;
4184        }
4185        let old_path = parse_patch_path(lines[index].trim_end_matches(['\r', '\n']), "--- ")?;
4186        index += 1;
4187        let Some(next) = lines.get(index) else {
4188            return Err(GitPatchError::Invalid("missing +++ header".to_owned()));
4189        };
4190        let new_path = parse_patch_path(next.trim_end_matches(['\r', '\n']), "+++ ")?;
4191        index += 1;
4192        let mut hunks = Vec::new();
4193        while index < lines.len() && !lines[index].starts_with("--- ") {
4194            if !lines[index].starts_with("@@ ") {
4195                index += 1;
4196                continue;
4197            }
4198            let old_start = parse_hunk_old_start(lines[index])?;
4199            index += 1;
4200            let mut hunk_lines: Vec<String> = Vec::new();
4201            while index < lines.len()
4202                && !lines[index].starts_with("@@ ")
4203                && !lines[index].starts_with("--- ")
4204            {
4205                let line = lines[index];
4206                if line.starts_with('\\') {
4207                    let previous = hunk_lines.last_mut().ok_or_else(|| {
4208                        GitPatchError::Invalid(
4209                            "no-newline marker does not follow a hunk line".to_owned(),
4210                        )
4211                    })?;
4212                    if previous.ends_with('\n') {
4213                        previous.pop();
4214                        if previous.ends_with('\r') {
4215                            previous.pop();
4216                        }
4217                    }
4218                } else if line.starts_with([' ', '+', '-']) {
4219                    hunk_lines.push(line.to_owned());
4220                } else {
4221                    return Err(GitPatchError::Invalid(
4222                        "hunk line lacks a unified-diff prefix".to_owned(),
4223                    ));
4224                }
4225                index += 1;
4226            }
4227            hunks.push(UnifiedHunk {
4228                old_start,
4229                lines: hunk_lines,
4230            });
4231        }
4232        if hunks.is_empty() {
4233            return Err(GitPatchError::Invalid("patch contains no hunks".to_owned()));
4234        }
4235        patches.push(UnifiedPatch {
4236            old_path,
4237            new_path,
4238            hunks,
4239        });
4240    }
4241    if patches.is_empty() {
4242        return Err(GitPatchError::Invalid("patch contains no files".to_owned()));
4243    }
4244    Ok(patches)
4245}
4246
4247fn parse_patch_path(line: &str, prefix: &str) -> Result<Option<String>, GitPatchError> {
4248    let value = line
4249        .strip_prefix(prefix)
4250        .ok_or_else(|| GitPatchError::Invalid(format!("missing {prefix}header")))?
4251        .split('\t')
4252        .next()
4253        .unwrap_or_default();
4254    if value == "/dev/null" {
4255        return Ok(None);
4256    }
4257    let value = value
4258        .strip_prefix("a/")
4259        .or_else(|| value.strip_prefix("b/"))
4260        .unwrap_or(value)
4261        .trim_matches('/');
4262    if value.is_empty()
4263        || value.contains('\\')
4264        || value.contains(':')
4265        || value
4266            .split('/')
4267            .any(|component| component.is_empty() || component == "." || component == "..")
4268    {
4269        return Err(GitPatchError::Invalid(
4270            "patch path escapes its root".to_owned(),
4271        ));
4272    }
4273    Ok(Some(value.to_owned()))
4274}
4275
4276fn parse_hunk_old_start(line: &str) -> Result<usize, GitPatchError> {
4277    let range = line
4278        .strip_prefix("@@ -")
4279        .and_then(|line| line.split_whitespace().next())
4280        .ok_or_else(|| GitPatchError::Invalid("malformed hunk header".to_owned()))?;
4281    range
4282        .split(',')
4283        .next()
4284        .and_then(|value| value.parse().ok())
4285        .ok_or_else(|| GitPatchError::Invalid("malformed hunk source range".to_owned()))
4286}
4287
4288fn apply_unified_hunks(
4289    path: &str,
4290    original: &str,
4291    hunks: &[UnifiedHunk],
4292) -> Result<String, GitPatchError> {
4293    let source: Vec<_> = original.split_inclusive('\n').collect();
4294    let mut output = String::new();
4295    let mut cursor = 0_usize;
4296    for hunk in hunks {
4297        let start = hunk.old_start.saturating_sub(1);
4298        if start < cursor || start > source.len() {
4299            return Err(GitPatchError::Conflict {
4300                path: path.to_owned(),
4301                line: hunk.old_start,
4302            });
4303        }
4304        for line in source.get(cursor..start).unwrap_or_default() {
4305            output.push_str(line);
4306        }
4307        cursor = start;
4308        for line in &hunk.lines {
4309            let Some(prefix) = line.chars().next() else {
4310                continue;
4311            };
4312            let payload = line.get(prefix.len_utf8()..).unwrap_or_default();
4313            match prefix {
4314                ' ' | '-' => {
4315                    if source.get(cursor).copied() != Some(payload) {
4316                        return Err(GitPatchError::Conflict {
4317                            path: path.to_owned(),
4318                            line: cursor.saturating_add(1),
4319                        });
4320                    }
4321                    if prefix == ' ' {
4322                        output.push_str(payload);
4323                    }
4324                    cursor = cursor.saturating_add(1);
4325                }
4326                '+' => output.push_str(payload),
4327                _ => {
4328                    return Err(GitPatchError::Invalid(
4329                        "unsupported hunk line prefix".to_owned(),
4330                    ));
4331                }
4332            }
4333        }
4334    }
4335    for line in source.get(cursor..).unwrap_or_default() {
4336        output.push_str(line);
4337    }
4338    Ok(output)
4339}
4340
4341const fn git_kind(kind: FileKind) -> &'static str {
4342    match kind {
4343        FileKind::Regular => "regular",
4344        FileKind::Directory => "directory",
4345        FileKind::SymbolicLink => "symbolic-link",
4346        FileKind::Fifo => "fifo",
4347        FileKind::Socket => "socket",
4348        FileKind::CharacterDevice => "character-device",
4349        FileKind::BlockDevice => "block-device",
4350        FileKind::ReparsePoint => "windows-reparse-point",
4351        FileKind::MountBoundary => "mount-boundary",
4352    }
4353}
4354
4355/// Failure while producing an ignore-aware compatibility snapshot.
4356#[derive(Debug, Error)]
4357pub enum GitCaptureError {
4358    /// Workspace traversal, mutation, or publication failed.
4359    #[error(transparent)]
4360    Workspace(#[from] WorkspaceError),
4361    /// A host-native name cannot be represented by Git's portable UTF-8 paths.
4362    #[error("Git compatibility cannot represent a non-UTF-8 path name")]
4363    NonPortableName,
4364    /// A deterministic capture workspace was concurrently changed.
4365    #[error("Git compatibility capture workspace changed concurrently")]
4366    Conflict,
4367}
4368
4369/// Failure to authenticate and durably register one filtered Git capture.
4370#[derive(Debug, Error)]
4371pub enum GitCaptureAuthenticationError<E: std::error::Error + 'static> {
4372    /// The immutable captured generation did not match its SDK fork.
4373    #[error(transparent)]
4374    Capture(#[from] GitCaptureError),
4375    /// The direct-parent lineage could not be durably registered.
4376    #[error(transparent)]
4377    Lineage(#[from] crate::WorkspaceLineageError<E>),
4378}
4379
4380/// Exact Git-visible change counts without creating a compatibility snapshot.
4381#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
4382pub struct GitDiffCounts {
4383    /// Changed eligible file records, counted once across hard-link aliases.
4384    pub file_changes: usize,
4385    /// Changed eligible namespace bindings.
4386    pub binding_changes: usize,
4387}
4388
4389/// Compares exact generations using the ignore policy belonging to each side.
4390///
4391/// Unlike commit capture, this read-only operation never forks or pins a
4392/// workspace. Only ignore files on changed paths are read.
4393pub async fn git_compatible_diff_counts<A, O>(
4394    from: &Generation<A, O>,
4395    to: &Generation<A, O>,
4396    already_tracked: &BTreeSet<String>,
4397    maximum_changes: u32,
4398    cancellation: &CancellationToken,
4399) -> Result<GitDiffCounts, GitCaptureError>
4400where
4401    A: AsyncAuthorityStore,
4402    O: AsyncObjectStore,
4403{
4404    let diff = from
4405        .diff_to_bounded(to, maximum_changes, WorkBudget::UNBOUNDED, cancellation)
4406        .await?;
4407    let paths = diff
4408        .changed_paths_bounded(maximum_changes, WorkBudget::UNBOUNDED, cancellation)
4409        .await?
4410        .value;
4411    let mut before_policies = BTreeMap::new();
4412    let mut after_policies = BTreeMap::new();
4413    let mut files = BTreeSet::new();
4414    let mut bindings = 0_usize;
4415    for change in paths {
4416        cancellation.check().map_err(WorkspaceError::from)?;
4417        let path = portable_changed_path(&change.path)?;
4418        let record = change.after.or(change.before);
4419        let Some(record) = record else { continue };
4420        if record.kind == FileKind::Directory {
4421            continue;
4422        }
4423        let (generation, policies) = if change.after.is_some() {
4424            (to, &mut after_policies)
4425        } else {
4426            (from, &mut before_policies)
4427        };
4428        if !git_path_eligible_at(generation, policies, &path, already_tracked).await? {
4429            continue;
4430        }
4431        let comparable = |record: Option<crate::kernel::FileRecord>| {
4432            record.map(|mut record| {
4433                record.link_count = 0;
4434                record
4435            })
4436        };
4437        if comparable(change.before) != comparable(change.after) {
4438            files.insert(record.file_id);
4439        }
4440        if change.before.map(|record| record.file_id) != change.after.map(|record| record.file_id) {
4441            bindings = bindings.saturating_add(1);
4442        }
4443    }
4444    Ok(GitDiffCounts {
4445        file_changes: files.len(),
4446        binding_changes: bindings,
4447    })
4448}
4449
4450async fn git_path_eligible_at<A, O>(
4451    generation: &Generation<A, O>,
4452    policies: &mut BTreeMap<String, GitIgnorePolicy>,
4453    path: &str,
4454    already_tracked: &BTreeSet<String>,
4455) -> Result<bool, GitCaptureError>
4456where
4457    A: AsyncAuthorityStore,
4458    O: AsyncObjectStore,
4459{
4460    load_git_ignore_policy(generation, policies, "").await?;
4461    let root = policies.get("").cloned().unwrap_or_default();
4462    git_path_eligible_with_root_at(generation, policies, &root, path, false, already_tracked).await
4463}
4464
4465async fn git_path_eligible_with_root_at<A, O>(
4466    generation: &Generation<A, O>,
4467    policies: &mut BTreeMap<String, GitIgnorePolicy>,
4468    root: &GitIgnorePolicy,
4469    path: &str,
4470    is_directory: bool,
4471    already_tracked: &BTreeSet<String>,
4472) -> Result<bool, GitCaptureError>
4473where
4474    A: AsyncAuthorityStore,
4475    O: AsyncObjectStore,
4476{
4477    if already_tracked.contains(path) {
4478        return Ok(true);
4479    }
4480    let mut directory = String::new();
4481    let components = path.split('/').collect::<Vec<_>>();
4482    for component in components.iter().take(components.len().saturating_sub(1)) {
4483        load_git_ignore_policy(generation, policies, &directory).await?;
4484        if !directory.is_empty() {
4485            directory.push('/');
4486        }
4487        directory.push_str(component);
4488        let prefix = format!("{directory}/");
4489        let tracked_descendant = already_tracked
4490            .range(prefix.clone()..)
4491            .next()
4492            .is_some_and(|candidate| candidate.starts_with(&prefix));
4493        if !tracked_descendant && !git_path_eligible(root, policies, &directory, true, false) {
4494            return Ok(false);
4495        }
4496    }
4497    load_git_ignore_policy(generation, policies, &directory).await?;
4498    Ok(git_path_eligible(root, policies, path, is_directory, false))
4499}
4500
4501async fn load_git_ignore_policy<A, O>(
4502    generation: &Generation<A, O>,
4503    policies: &mut BTreeMap<String, GitIgnorePolicy>,
4504    directory: &str,
4505) -> Result<(), GitCaptureError>
4506where
4507    A: AsyncAuthorityStore,
4508    O: AsyncObjectStore,
4509{
4510    if policies.contains_key(directory) {
4511        return Ok(());
4512    }
4513    let path = if directory.is_empty() {
4514        "/.gitignore".to_owned()
4515    } else {
4516        format!("/{directory}/.gitignore")
4517    };
4518    let policy = match generation.read(&path, 1024 * 1024).await {
4519        Ok(contents) => {
4520            let contents =
4521                std::str::from_utf8(&contents).map_err(|_| GitCaptureError::NonPortableName)?;
4522            GitIgnorePolicy::parse(contents)
4523        }
4524        Err(WorkspaceError::NotFound) => GitIgnorePolicy::default(),
4525        Err(error) => return Err(error.into()),
4526    };
4527    policies.insert(directory.to_owned(), policy);
4528    Ok(())
4529}
4530
4531async fn scan_git_capture_entries<A, O>(
4532    live: &crate::Generation<A, O>,
4533) -> Result<(Vec<(String, bool)>, BTreeMap<String, GitIgnorePolicy>), GitCaptureError>
4534where
4535    A: AsyncAuthorityStore,
4536    O: AsyncObjectStore,
4537{
4538    let mut entries = Vec::new();
4539    let mut nested_policies = BTreeMap::new();
4540    let mut pending = vec!["/".to_owned()];
4541    while let Some(directory) = pending.pop() {
4542        let mut after = None;
4543        loop {
4544            let page = live
4545                .list_directory(&directory, after.as_ref(), 1_024)
4546                .await?;
4547            for entry in &page.entries {
4548                let name = entry
4549                    .name
4550                    .unicode_text()
4551                    .ok_or(GitCaptureError::NonPortableName)?;
4552                let path = if directory == "/" {
4553                    name.to_string()
4554                } else {
4555                    format!("{}/{}", directory.trim_start_matches('/'), name)
4556                };
4557                let is_directory = entry.kind == FileKind::Directory;
4558                entries.push((path.clone(), is_directory));
4559                if is_directory {
4560                    pending.push(format!("/{path}"));
4561                } else if name == ".gitignore" && directory != "/" {
4562                    let contents = live.read(&format!("/{path}"), 1024 * 1024).await?;
4563                    let contents = std::str::from_utf8(&contents)
4564                        .map_err(|_| GitCaptureError::NonPortableName)?;
4565                    nested_policies.insert(
4566                        directory.trim_start_matches('/').to_owned(),
4567                        GitIgnorePolicy::parse(contents),
4568                    );
4569                }
4570            }
4571            if !page.has_more {
4572                break;
4573            }
4574            after = page.entries.last().map(|entry| entry.name.clone());
4575        }
4576    }
4577    Ok((entries, nested_policies))
4578}
4579
4580/// Captures the exact eligible compatibility tree without changing the live
4581/// workspace. Already tracked paths remain eligible even when newly ignored.
4582///
4583/// The filtered snapshot is a cheap SDK fork. Its publication and retention
4584/// identities derive from `operation_id`, so interrupted captures resume
4585/// without rescanning or duplicating history.
4586pub async fn capture_git_compatible_generation<A, O>(
4587    workspace: &Workspace<A, O>,
4588    policy: &GitIgnorePolicy,
4589    already_tracked: &BTreeSet<String>,
4590    operation_id: OperationId,
4591) -> Result<GitCapturedGeneration<A, O>, GitCaptureError>
4592where
4593    A: AsyncAuthorityStore,
4594    O: AsyncObjectStore,
4595{
4596    let live = workspace.head().await?;
4597    capture_git_compatible_generation_from(workspace, live, policy, already_tracked, operation_id)
4598        .await
4599}
4600
4601/// Captures an ignore-aware compatibility tree from one exact immutable
4602/// workspace generation. Later workspace updates cannot leak into the result.
4603pub async fn capture_git_compatible_generation_at<A, O>(
4604    workspace: &Workspace<A, O>,
4605    generation: GenerationId,
4606    policy: &GitIgnorePolicy,
4607    already_tracked: &BTreeSet<String>,
4608    operation_id: OperationId,
4609) -> Result<GitCapturedGeneration<A, O>, GitCaptureError>
4610where
4611    A: AsyncAuthorityStore,
4612    O: AsyncObjectStore,
4613{
4614    let live = workspace.generation(generation).await?;
4615    capture_git_compatible_generation_from(workspace, live, policy, already_tracked, operation_id)
4616        .await
4617}
4618
4619async fn capture_git_compatible_generation_from<A, O>(
4620    workspace: &Workspace<A, O>,
4621    live: crate::Generation<A, O>,
4622    policy: &GitIgnorePolicy,
4623    already_tracked: &BTreeSet<String>,
4624    operation_id: OperationId,
4625) -> Result<GitCapturedGeneration<A, O>, GitCaptureError>
4626where
4627    A: AsyncAuthorityStore,
4628    O: AsyncObjectStore,
4629{
4630    let (mut entries, nested_policies) = scan_git_capture_entries(&live).await?;
4631    entries.sort_by(|left, right| {
4632        path_depth(&left.0)
4633            .cmp(&path_depth(&right.0))
4634            .then_with(|| left.0.cmp(&right.0))
4635    });
4636    let mut excluded = Vec::<String>::new();
4637    let mut tracked_paths = BTreeSet::new();
4638    for (path, is_directory) in &entries {
4639        let parent_excluded = excluded.iter().any(|parent| is_path_below(path, parent));
4640        let tracked = already_tracked.contains(path);
4641        let tracked_descendant = *is_directory
4642            && already_tracked
4643                .iter()
4644                .any(|candidate| is_path_below(candidate, path));
4645        if parent_excluded
4646            || (!git_path_eligible(policy, &nested_policies, path, *is_directory, tracked)
4647                && !tracked_descendant)
4648        {
4649            excluded.push(path.clone());
4650        } else if !is_directory {
4651            tracked_paths.insert(path.clone());
4652        }
4653    }
4654    if excluded.is_empty() {
4655        return Ok(GitCapturedGeneration {
4656            generation: live,
4657            initial_generation: None,
4658            tracked_paths,
4659            fork_parent: None,
4660        });
4661    }
4662    let bytes = operation_id.into_bytes();
4663    let workspace_hash = hex::encode(&workspace.id().into_bytes()[..6]);
4664    let capture_name = format!("git-capture-{workspace_hash}-{}", hex::encode(bytes));
4665    let capture = workspace
4666        .fork(
4667            capture_name,
4668            crate::ForkOptions::from_generation(live.clone(), IdempotencyKey::from_bytes(bytes)),
4669        )
4670        .await?;
4671    let capture_head = capture.head().await?;
4672    let mut commit_bytes = bytes;
4673    commit_bytes[0] ^= 0xa5;
4674    let commit_key = IdempotencyKey::from_bytes(commit_bytes);
4675    let (generation, initial_generation) = if let Some(generation) =
4676        capture.operation_generation(commit_key).await?
4677    {
4678        let parents = generation.parents().await?;
4679        let [initial_generation] = parents.as_slice() else {
4680            return Err(GitCaptureError::Conflict);
4681        };
4682        (generation, *initial_generation)
4683    } else {
4684        let mut transaction = capture.begin_transaction(commit_key).await?;
4685        for path in excluded.iter().rev() {
4686            // An unobserved lazy path may disappear between scanning the live
4687            // source and first observation through the fork. It is already
4688            // absent from the compatibility snapshot in that case.
4689            match transaction.remove(&format!("/{path}")).await {
4690                Ok(()) | Err(WorkspaceError::NotFound) => {}
4691                Err(error) => return Err(error.into()),
4692            }
4693        }
4694        match transaction.commit().await? {
4695            TransactionCommit::Committed(generation)
4696            | TransactionCommit::AlreadyCommitted(generation) => (generation, capture_head.id()),
4697            TransactionCommit::Conflict { .. }
4698            | TransactionCommit::Fenced
4699            | TransactionCommit::IdempotencyConflict => return Err(GitCaptureError::Conflict),
4700        }
4701    };
4702    let pin = format!("capture-{}", hex::encode(bytes));
4703    generation.pin(pin).await?;
4704    Ok(GitCapturedGeneration {
4705        generation,
4706        initial_generation: Some(initial_generation),
4707        tracked_paths,
4708        fork_parent: Some(live),
4709    })
4710}
4711
4712/// Incrementally captures only paths changed since the preceding live
4713/// generation, starting from the preceding filtered compatibility snapshot.
4714///
4715/// Equal Merkle subtrees are skipped. A `.gitignore` change deliberately
4716/// falls back to an exact capture because eligibility may have changed for an
4717/// otherwise unchanged path.
4718#[allow(clippy::too_many_lines)] // Eligibility and the single atomic path application form one operation.
4719pub async fn capture_git_compatible_generation_incremental<A, O>(
4720    workspace: &Workspace<A, O>,
4721    previous_live: &Generation<A, O>,
4722    previous_capture: &Generation<A, O>,
4723    policy: &GitIgnorePolicy,
4724    already_tracked: &BTreeSet<String>,
4725    operation_id: OperationId,
4726) -> Result<GitCapturedGeneration<A, O>, GitCaptureError>
4727where
4728    A: AsyncAuthorityStore,
4729    O: AsyncObjectStore,
4730{
4731    let live = workspace.head().await?;
4732    let changes = previous_live.diff_to(&live, u32::MAX).await?;
4733    let changed = changes.changed_paths(u32::MAX).await?;
4734    let mut portable = Vec::with_capacity(changed.len());
4735    for change in changed {
4736        let path = portable_changed_path(&change.path)?;
4737        if path.rsplit('/').next() == Some(".gitignore") {
4738            return capture_git_compatible_generation(
4739                workspace,
4740                policy,
4741                already_tracked,
4742                operation_id,
4743            )
4744            .await;
4745        }
4746        portable.push((path, change.before, change.after));
4747    }
4748
4749    let mut eligible = Vec::new();
4750    let mut tracked_paths = already_tracked.clone();
4751    let mut before_policies = BTreeMap::new();
4752    let mut after_policies = BTreeMap::new();
4753    for (path, before, after) in portable {
4754        let kind = after.or(before).map(|record| record.kind);
4755        let is_directory = kind == Some(FileKind::Directory);
4756        let tracked = already_tracked.contains(&path);
4757        let tracked_descendant = is_directory
4758            && already_tracked
4759                .iter()
4760                .any(|candidate| is_path_below(candidate, &path));
4761        let (generation, policies) = if after.is_some() {
4762            (&live, &mut after_policies)
4763        } else {
4764            (previous_live, &mut before_policies)
4765        };
4766        let allowed = git_path_eligible_with_root_at(
4767            generation,
4768            policies,
4769            policy,
4770            &path,
4771            is_directory,
4772            already_tracked,
4773        )
4774        .await?;
4775        if tracked || tracked_descendant || allowed {
4776            eligible.push(path.clone());
4777        }
4778        if !is_directory {
4779            if after.is_some() && (tracked || allowed) {
4780                tracked_paths.insert(path);
4781            } else {
4782                tracked_paths.remove(&path);
4783            }
4784        }
4785    }
4786    if eligible.is_empty() {
4787        return Ok(GitCapturedGeneration {
4788            generation: previous_capture.clone(),
4789            initial_generation: None,
4790            tracked_paths,
4791            fork_parent: None,
4792        });
4793    }
4794
4795    let bytes = operation_id.into_bytes();
4796    // This fork is a child of the previous filtered capture, not the live
4797    // workspace. Bind its stable name to its actual parent so retries cannot
4798    // reuse an identity derived from a different lineage.
4799    let workspace_hash = hex::encode(&previous_capture.workspace_id().into_bytes()[..6]);
4800    let capture_name = format!("git-capture-{workspace_hash}-{}", hex::encode(bytes));
4801    let capture = previous_capture
4802        .workspace()
4803        .fork(
4804            capture_name,
4805            crate::ForkOptions::from_generation(
4806                previous_capture.clone(),
4807                IdempotencyKey::from_bytes(bytes),
4808            ),
4809        )
4810        .await?;
4811    let mut apply_bytes = bytes;
4812    apply_bytes[0] ^= 0xa5;
4813    let capture_head = capture.head().await?;
4814    let (generation, initial_generation) = match capture
4815        .apply_paths_from(
4816            Some(previous_live),
4817            Some(&live),
4818            &eligible,
4819            capture_head.id(),
4820            IdempotencyKey::from_bytes(apply_bytes),
4821        )
4822        .await?
4823    {
4824        crate::WorkspacePathApply::Applied(generation)
4825        | crate::WorkspacePathApply::NoChanges(generation) => (generation, capture_head.id()),
4826        crate::WorkspacePathApply::AlreadyApplied(generation) => {
4827            let parents = generation.parents().await?;
4828            let [initial_generation] = parents.as_slice() else {
4829                return Err(GitCaptureError::Conflict);
4830            };
4831            (generation, *initial_generation)
4832        }
4833        crate::WorkspacePathApply::Conflicted(_)
4834        | crate::WorkspacePathApply::Stale(_)
4835        | crate::WorkspacePathApply::Fenced
4836        | crate::WorkspacePathApply::IdempotencyConflict => return Err(GitCaptureError::Conflict),
4837    };
4838    generation
4839        .pin(format!("capture-{}", hex::encode(bytes)))
4840        .await?;
4841    Ok(GitCapturedGeneration {
4842        generation,
4843        initial_generation: Some(initial_generation),
4844        tracked_paths,
4845        fork_parent: Some(previous_capture.clone()),
4846    })
4847}
4848
4849fn portable_changed_path(path: &crate::kernel::NamespacePath) -> Result<String, GitCaptureError> {
4850    let mut portable = String::new();
4851    for component in path.components() {
4852        if !portable.is_empty() {
4853            portable.push('/');
4854        }
4855        portable.push_str(
4856            &component
4857                .unicode_text()
4858                .ok_or(GitCaptureError::NonPortableName)?,
4859        );
4860    }
4861    Ok(portable)
4862}
4863
4864fn path_depth(path: &str) -> usize {
4865    path.split('/').count()
4866}
4867
4868fn is_path_below(candidate: &str, parent: &str) -> bool {
4869    candidate
4870        .strip_prefix(parent)
4871        .is_some_and(|suffix| suffix.starts_with('/'))
4872}
4873
4874#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
4875struct GitIgnoreRule {
4876    pattern: String,
4877    negated: bool,
4878    directory_only: bool,
4879    anchored: bool,
4880}
4881
4882impl GitIgnorePolicy {
4883    /// Identifies changed, newly ignored paths whose child-side binding wins
4884    /// during compatibility publication. Already tracked paths retain normal
4885    /// filesystem merge behavior.
4886    pub async fn newly_ignored_paths_at<A, O>(
4887        &self,
4888        generation: &Generation<A, O>,
4889        changes: &[crate::ChangedPath],
4890        already_tracked: &BTreeSet<String>,
4891    ) -> Result<BTreeSet<String>, GitCaptureError>
4892    where
4893        A: AsyncAuthorityStore,
4894        O: AsyncObjectStore,
4895    {
4896        let mut paths = BTreeSet::new();
4897        let mut nested_policies = BTreeMap::new();
4898        for change in changes {
4899            let path = portable_changed_path(&change.path)?;
4900            let is_directory = change
4901                .after
4902                .or(change.before)
4903                .is_some_and(|record| record.kind == FileKind::Directory);
4904            // A directory binding can contain tracked or independently changed
4905            // descendants. Child-wins publication is pathwise for newly ignored
4906            // files, never a replacement of the enclosing subtree.
4907            if is_directory {
4908                continue;
4909            }
4910            if !git_path_eligible_with_root_at(
4911                generation,
4912                &mut nested_policies,
4913                self,
4914                &path,
4915                is_directory,
4916                already_tracked,
4917            )
4918            .await?
4919            {
4920                paths.insert(format!("/{path}"));
4921            }
4922        }
4923        Ok(paths)
4924    }
4925
4926    /// Parses Gitignore-shaped UTF-8 lines.
4927    #[must_use]
4928    pub fn parse(contents: &str) -> Self {
4929        let rules = contents
4930            .lines()
4931            .filter_map(|line| {
4932                let mut line = line.trim_end_matches('\r');
4933                if line.is_empty() || line.starts_with('#') {
4934                    return None;
4935                }
4936                let negated = line.starts_with('!');
4937                if negated {
4938                    line = line.strip_prefix('!').unwrap_or(line);
4939                }
4940                let anchored = line.starts_with('/');
4941                if anchored {
4942                    line = line.strip_prefix('/').unwrap_or(line);
4943                }
4944                let directory_only = line.ends_with('/');
4945                if directory_only {
4946                    line = line.trim_end_matches('/');
4947                }
4948                if line.is_empty() {
4949                    return None;
4950                }
4951                Some(GitIgnoreRule {
4952                    pattern: line.to_owned(),
4953                    negated,
4954                    directory_only,
4955                    anchored,
4956                })
4957            })
4958            .collect();
4959        Self { rules }
4960    }
4961
4962    /// Returns whether a path participates in compatibility commit/join state.
4963    #[must_use]
4964    pub fn eligible(&self, path: &str, is_directory: bool, already_tracked: bool) -> bool {
4965        if already_tracked {
4966            return true;
4967        }
4968        let path = path.trim_start_matches('/');
4969        let mut ignored = false;
4970        for rule in &self.rules {
4971            if ignore_matches(rule, path, is_directory) {
4972                ignored = !rule.negated;
4973            }
4974        }
4975        !ignored
4976    }
4977
4978    fn apply(&self, path: &str, is_directory: bool, ignored: &mut bool) {
4979        for rule in &self.rules {
4980            if ignore_matches(rule, path, is_directory) {
4981                *ignored = !rule.negated;
4982            }
4983        }
4984    }
4985}
4986
4987fn git_path_eligible(
4988    root: &GitIgnorePolicy,
4989    nested: &BTreeMap<String, GitIgnorePolicy>,
4990    path: &str,
4991    is_directory: bool,
4992    already_tracked: bool,
4993) -> bool {
4994    if already_tracked {
4995        return true;
4996    }
4997    let path = path.trim_start_matches('/');
4998    let mut ignored = false;
4999    root.apply(path, is_directory, &mut ignored);
5000    let mut applicable = nested
5001        .iter()
5002        .filter_map(|(directory, policy)| {
5003            path.strip_prefix(directory)
5004                .and_then(|suffix| suffix.strip_prefix('/'))
5005                .map(|relative| (directory.split('/').count(), relative, policy))
5006        })
5007        .collect::<Vec<_>>();
5008    applicable.sort_by_key(|(depth, _, _)| *depth);
5009    for (_, relative, policy) in applicable {
5010        policy.apply(relative, is_directory, &mut ignored);
5011    }
5012    !ignored
5013}
5014
5015fn ignore_matches(rule: &GitIgnoreRule, path: &str, is_directory: bool) -> bool {
5016    if rule.directory_only {
5017        let components: Vec<_> = path.split('/').collect();
5018        let directory_count = if is_directory {
5019            components.len()
5020        } else {
5021            components.len().saturating_sub(1)
5022        };
5023        return (1..=directory_count).any(|end| {
5024            let prefix = components.get(..end).unwrap_or_default();
5025            let directory = prefix.join("/");
5026            if rule.anchored || rule.pattern.contains('/') {
5027                wildcard_matches(&rule.pattern, &directory)
5028            } else {
5029                prefix
5030                    .iter()
5031                    .any(|component| wildcard_matches(&rule.pattern, component))
5032            }
5033        });
5034    }
5035    if rule.anchored || rule.pattern.contains('/') {
5036        wildcard_matches(&rule.pattern, path)
5037    } else {
5038        path.split('/')
5039            .any(|component| wildcard_matches(&rule.pattern, component))
5040    }
5041}
5042
5043fn wildcard_matches(pattern: &str, value: &str) -> bool {
5044    let pattern = pattern.as_bytes();
5045    let value = value.as_bytes();
5046    let (mut pattern_index, mut value_index, mut star, mut checkpoint) = (0, 0, None, 0);
5047    while value_index < value.len() {
5048        if pattern.get(pattern_index).is_some_and(|byte| {
5049            *byte == b'?' || value.get(value_index).is_some_and(|value| value == byte)
5050        }) {
5051            pattern_index += 1;
5052            value_index += 1;
5053        } else if pattern.get(pattern_index) == Some(&b'*') {
5054            star = Some(pattern_index);
5055            pattern_index += 1;
5056            checkpoint = value_index;
5057        } else if let Some(star_index) = star {
5058            pattern_index = star_index + 1;
5059            checkpoint += 1;
5060            value_index = checkpoint;
5061        } else {
5062            return false;
5063        }
5064    }
5065    while pattern.get(pattern_index) == Some(&b'*') {
5066        pattern_index += 1;
5067    }
5068    pattern_index == pattern.len()
5069}
5070
5071/// Process-local compatibility state adapter for tests and embedded callers.
5072#[derive(Default)]
5073pub struct MemoryGitCompatStore {
5074    states: Mutex<BTreeMap<WorkspaceId, GitCompatState>>,
5075}
5076
5077impl MemoryGitCompatStore {
5078    /// Creates an empty adapter.
5079    #[must_use]
5080    pub fn new() -> Self {
5081        Self::default()
5082    }
5083}
5084
5085/// Process-local compatibility store synchronization failure.
5086#[derive(Debug, Error)]
5087#[error("Git compatibility memory store is unavailable")]
5088pub struct MemoryGitCompatStoreError;
5089
5090impl GitCompatStore for MemoryGitCompatStore {
5091    type Error = MemoryGitCompatStoreError;
5092
5093    async fn load(&self, workspace_id: WorkspaceId) -> Result<Option<GitCompatState>, Self::Error> {
5094        self.states
5095            .lock()
5096            .map_err(|_| MemoryGitCompatStoreError)
5097            .map(|states| states.get(&workspace_id).cloned())
5098    }
5099
5100    async fn compare_and_swap(
5101        &self,
5102        workspace_id: WorkspaceId,
5103        expected_revision: u64,
5104        replacement: GitCompatState,
5105    ) -> Result<bool, Self::Error> {
5106        let mut states = self.states.lock().map_err(|_| MemoryGitCompatStoreError)?;
5107        let revision = states.get(&workspace_id).map_or(0, |state| state.revision);
5108        if revision != expected_revision {
5109            return Ok(false);
5110        }
5111        states.insert(workspace_id, replacement);
5112        Ok(true)
5113    }
5114
5115    async fn compare_and_delete(
5116        &self,
5117        workspace_id: WorkspaceId,
5118        expected_revision: u64,
5119    ) -> Result<bool, Self::Error> {
5120        let mut states = self.states.lock().map_err(|_| MemoryGitCompatStoreError)?;
5121        let revision = states.get(&workspace_id).map_or(0, |state| state.revision);
5122        if revision != expected_revision {
5123            return Ok(false);
5124        }
5125        states.remove(&workspace_id);
5126        Ok(true)
5127    }
5128}
5129
5130#[cfg(test)]
5131#[allow(clippy::expect_used, clippy::panic)]
5132mod tests {
5133    use super::*;
5134    use crate::kernel::{FileMetadata, MetadataField};
5135    use crate::{Digest, Fs, WorkspaceName};
5136
5137    #[derive(Debug, Error)]
5138    #[error("test executor failure")]
5139    struct TestExecutorError;
5140
5141    struct TestExecutor {
5142        result: Result<GitFilesystemResult, TestExecutorError>,
5143        operations: Mutex<Vec<OperationId>>,
5144    }
5145
5146    impl TestExecutor {
5147        fn returning(result: GitFilesystemResult) -> Self {
5148            Self {
5149                result: Ok(result),
5150                operations: Mutex::new(Vec::new()),
5151            }
5152        }
5153
5154        fn failing() -> Self {
5155            Self {
5156                result: Err(TestExecutorError),
5157                operations: Mutex::new(Vec::new()),
5158            }
5159        }
5160    }
5161
5162    impl GitFilesystemExecutor for TestExecutor {
5163        type Error = TestExecutorError;
5164
5165        async fn execute(
5166            &self,
5167            operation_id: OperationId,
5168            _action: &GitFilesystemAction,
5169        ) -> Result<GitFilesystemResult, Self::Error> {
5170            self.operations
5171                .lock()
5172                .expect("operation lock")
5173                .push(operation_id);
5174            self.result
5175                .as_ref()
5176                .map(Clone::clone)
5177                .map_err(|_| TestExecutorError)
5178        }
5179    }
5180
5181    fn generation(byte: u8) -> GenerationId {
5182        GenerationId::new(Digest::from_bytes([byte; 32]))
5183    }
5184
5185    fn workspace() -> WorkspaceId {
5186        WorkspaceId::derive(
5187            [9; 16],
5188            &WorkspaceName::new("repo").expect("valid test workspace"),
5189        )
5190    }
5191
5192    fn tree(byte: u8) -> GitTreeRef {
5193        GitTreeRef::exact(workspace(), generation(byte))
5194    }
5195
5196    #[test]
5197    fn git_wire_identity_contract_matches_native_bytes() {
5198        let operation = OperationId::from_bytes(std::array::from_fn(|index| {
5199            u8::try_from(index).unwrap_or_default()
5200        }));
5201        let generation = GenerationId::new(Digest::from_bytes(std::array::from_fn(|index| {
5202            u8::try_from(index).unwrap_or_default()
5203        })));
5204        let tree = GitTreeRef::exact(WorkspaceId::from_bytes(operation.into_bytes()), generation);
5205        assert_eq!(
5206            serde_json::to_value(GitTransitionId(operation)).expect("transition JSON"),
5207            serde_json::json!("00010203-0405-0607-0809-0a0b0c0d0e0f")
5208        );
5209        assert_eq!(
5210            serde_json::to_value(GitCaptureProof {
5211                fork_parent: tree,
5212                initial_generation: generation,
5213                operation_id: operation,
5214            })
5215            .expect("proof JSON"),
5216            serde_json::json!({
5217                "fork_parent": {
5218                    "kind": "exact",
5219                    "workspace_id": (0u8..16).collect::<Vec<_>>(),
5220                    "generation": (0u8..32).collect::<Vec<_>>()
5221                },
5222                "initial_generation": (0u8..32).collect::<Vec<_>>(),
5223                "operation_id": "00010203-0405-0607-0809-0a0b0c0d0e0f"
5224            })
5225        );
5226    }
5227
5228    #[tokio::test]
5229    async fn foreign_workspace_tree_is_rejected_before_executor_side_effects() {
5230        let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
5231        let foreign_workspace = WorkspaceId::derive(
5232            [8; 16],
5233            &WorkspaceName::new("foreign").expect("valid foreign workspace"),
5234        );
5235        let executor = TestExecutor::returning(GitFilesystemResult::Applied {
5236            tree: None,
5237            tracked_paths: None,
5238        });
5239
5240        let error = repository
5241            .run(
5242                GitCommand::Status,
5243                GitTreeRef::exact(foreign_workspace, generation(1)),
5244                &executor,
5245            )
5246            .await
5247            .expect_err("foreign tree must fail closed");
5248
5249        assert!(matches!(
5250            error,
5251            GitCompatRunError::Compat(GitCompatError::WorkspaceMismatch)
5252        ));
5253        assert!(
5254            executor
5255                .operations
5256                .lock()
5257                .expect("operation lock")
5258                .is_empty(),
5259            "foreign trees must never reach the filesystem executor"
5260        );
5261    }
5262
5263    #[tokio::test]
5264    async fn foreign_completion_tree_is_rejected_without_consuming_the_transition() {
5265        let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
5266        let GitCommandOutput::Prepared { transition, .. } = repository
5267            .execute(
5268                GitCommand::Commit {
5269                    message: "initial".to_owned(),
5270                    author: "agent".to_owned(),
5271                    authored_at_seconds: 10,
5272                },
5273                generation(1),
5274            )
5275            .await
5276            .expect("prepare commit")
5277        else {
5278            panic!("expected prepared commit");
5279        };
5280        let foreign = WorkspaceId::derive(
5281            [8; 16],
5282            &WorkspaceName::new("foreign-result").expect("valid workspace"),
5283        );
5284        assert!(matches!(
5285            repository
5286                .complete_transition_result(
5287                    transition,
5288                    &GitFilesystemResult::Captured {
5289                        tree: GitTreeRef::exact(foreign, generation(1)),
5290                        tracked_paths: BTreeSet::new(),
5291                        proof: None,
5292                    },
5293                )
5294                .await,
5295            Err(GitCompatError::WorkspaceMismatch)
5296        ));
5297        assert_eq!(
5298            repository
5299                .pending_transition()
5300                .await
5301                .expect("pending transition")
5302                .map(|pending| pending.id),
5303            Some(transition)
5304        );
5305    }
5306
5307    #[tokio::test]
5308    async fn persisted_commit_hashes_are_validated_before_use() {
5309        let store = MemoryGitCompatStore::new();
5310        let mut state = GitCompatState::new("main", workspace());
5311        let mut commit = GitCommit::new(tree(1), Vec::new(), "agent", 10, "initial");
5312        commit.message = "forged".to_owned();
5313        state.revision = 1;
5314        state.commits.insert(commit.id, commit.clone());
5315        state.branches.get_mut("main").expect("main branch").head = Some(commit.id);
5316        {
5317            let mut states = store.states.lock().expect("state lock");
5318            states.insert(workspace(), state);
5319        }
5320        let repository = GitCompatRepository::new(workspace(), store);
5321        assert!(matches!(
5322            repository.execute(GitCommand::Status, generation(1)).await,
5323            Err(GitCompatError::InvalidState)
5324        ));
5325    }
5326
5327    #[tokio::test]
5328    async fn persisted_foreign_commit_tree_needs_capture_provenance() {
5329        let store = MemoryGitCompatStore::new();
5330        let mut state = GitCompatState::new("main", workspace());
5331        let foreign = WorkspaceId::derive(
5332            [8; 16],
5333            &WorkspaceName::new("foreign-persisted-tree").expect("workspace name"),
5334        );
5335        let commit = GitCommit::new_with_metadata(
5336            GitTreeRef::exact(foreign, generation(7)),
5337            tree(1),
5338            BTreeSet::from(["foreign.txt".to_owned()]),
5339            Vec::new(),
5340            "agent",
5341            10,
5342            "forged tree",
5343        );
5344        state.revision = 1;
5345        state.branches.get_mut("main").expect("branch").head = Some(commit.id);
5346        state
5347            .branches
5348            .get_mut("main")
5349            .expect("branch")
5350            .tracked_paths = commit.tracked_paths.clone();
5351        state.commits.insert(commit.id, commit);
5352        store
5353            .states
5354            .lock()
5355            .expect("state lock")
5356            .insert(workspace(), state);
5357        let repository = GitCompatRepository::new(workspace(), store);
5358        assert!(matches!(
5359            repository.execute(GitCommand::Status, generation(1)).await,
5360            Err(GitCompatError::InvalidState)
5361        ));
5362    }
5363
5364    #[test]
5365    fn generated_commit_uses_exact_resulting_tracked_paths() {
5366        let mut state = GitCompatState::new("main", workspace());
5367        state.current_mut().expect("current branch").tracked_paths =
5368            BTreeSet::from(["deleted.txt".to_owned(), "kept.txt".to_owned()]);
5369        let output = complete_pending::<MemoryGitCompatStoreError>(
5370            &mut state,
5371            GitPendingMutation::ApplyCommit {
5372                commit: GitCommitId::from_bytes([7; 32]),
5373                reverse: false,
5374            },
5375            &GitFilesystemResult::Applied {
5376                tree: Some(tree(2)),
5377                tracked_paths: Some(BTreeSet::from(["kept.txt".to_owned()])),
5378            },
5379        )
5380        .expect("generated commit");
5381        let GitCommandOutput::Committed(commit) = output else {
5382            panic!("expected generated commit");
5383        };
5384        assert_eq!(
5385            commit.tracked_paths,
5386            BTreeSet::from(["kept.txt".to_owned()])
5387        );
5388        assert_eq!(
5389            state.current().expect("current branch").tracked_paths,
5390            commit.tracked_paths
5391        );
5392    }
5393
5394    #[tokio::test]
5395    async fn auto_staging_commit_and_machine_readable_history() {
5396        let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
5397        let GitCommandOutput::Status(status) = repository
5398            .execute(GitCommand::Status, generation(1))
5399            .await
5400            .expect("status")
5401        else {
5402            panic!("expected status");
5403        };
5404        assert_eq!(status.dirty, GitDirtyState::Dirty);
5405        assert!(status.all_changes_staged);
5406        let GitCommandOutput::Prepared {
5407            transition,
5408            action: GitFilesystemAction::CaptureCommit { .. },
5409        } = repository
5410            .execute(
5411                GitCommand::Commit {
5412                    message: "initial".to_owned(),
5413                    author: "agent".to_owned(),
5414                    authored_at_seconds: 10,
5415                },
5416                generation(1),
5417            )
5418            .await
5419            .expect("capture commit")
5420        else {
5421            panic!("expected commit capture");
5422        };
5423        let GitCommandOutput::Committed(commit) = repository
5424            .complete_transition_result(
5425                transition,
5426                &GitFilesystemResult::Captured {
5427                    tree: tree(1),
5428                    tracked_paths: BTreeSet::from(["tracked.txt".to_owned()]),
5429                    proof: None,
5430                },
5431            )
5432            .await
5433            .expect("record commit")
5434        else {
5435            panic!("expected commit");
5436        };
5437        let GitCommandOutput::Commits(log) = repository
5438            .execute(GitCommand::Log { maximum: 10 }, generation(1))
5439            .await
5440            .expect("log")
5441        else {
5442            panic!("expected log");
5443        };
5444        assert_eq!(log, vec![commit]);
5445    }
5446
5447    #[tokio::test]
5448    async fn composed_run_finalizes_commit_and_branch_actions() {
5449        let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
5450        let commit_executor = TestExecutor::returning(GitFilesystemResult::Captured {
5451            tree: tree(9),
5452            tracked_paths: BTreeSet::from(["tracked.txt".to_owned()]),
5453            proof: None,
5454        });
5455        let GitCommandOutput::Committed(commit) = repository
5456            .run(
5457                GitCommand::Commit {
5458                    message: "initial".to_owned(),
5459                    author: "agent".to_owned(),
5460                    authored_at_seconds: 10,
5461                },
5462                generation(1),
5463                &commit_executor,
5464            )
5465            .await
5466            .expect("composed commit")
5467        else {
5468            panic!("expected composed commit");
5469        };
5470        assert_eq!(commit.tree, tree(9));
5471        assert_eq!(commit.workspace_tree, tree(1));
5472        assert!(matches!(
5473            repository
5474                .execute(GitCommand::Status, generation(1))
5475                .await
5476                .expect("status"),
5477            GitCommandOutput::Status(GitStatus {
5478                dirty: GitDirtyState::Clean,
5479                ..
5480            })
5481        ));
5482        let child = WorkspaceId::derive(
5483            [9; 16],
5484            &WorkspaceName::new("feature-run").expect("valid workspace name"),
5485        );
5486        let branch_executor = TestExecutor::returning(GitFilesystemResult::Forked {
5487            workspace_id: child,
5488        });
5489        repository
5490            .run(
5491                GitCommand::Branch {
5492                    create: Some("feature-run".to_owned()),
5493                },
5494                generation(1),
5495                &branch_executor,
5496            )
5497            .await
5498            .expect("composed branch");
5499        assert!(matches!(
5500            repository
5501                .execute(GitCommand::Branch { create: None }, generation(1))
5502                .await
5503                .expect("branches"),
5504            GitCommandOutput::Branches { branches, .. }
5505                if branches.iter().any(|branch| branch.workspace_id == child)
5506        ));
5507    }
5508
5509    #[tokio::test]
5510    async fn composed_run_leaves_failed_transition_for_exact_resume() {
5511        let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
5512        let commit_executor = TestExecutor::returning(GitFilesystemResult::Captured {
5513            tree: tree(1),
5514            tracked_paths: BTreeSet::new(),
5515            proof: None,
5516        });
5517        repository
5518            .run(
5519                GitCommand::Commit {
5520                    message: "initial".to_owned(),
5521                    author: "agent".to_owned(),
5522                    authored_at_seconds: 10,
5523                },
5524                generation(1),
5525                &commit_executor,
5526            )
5527            .await
5528            .expect("initial commit");
5529        let child = WorkspaceId::derive(
5530            [9; 16],
5531            &WorkspaceName::new("recover-run").expect("valid workspace name"),
5532        );
5533        repository
5534            .run(
5535                GitCommand::Branch {
5536                    create: Some("recover-run".to_owned()),
5537                },
5538                generation(1),
5539                &TestExecutor::returning(GitFilesystemResult::Forked {
5540                    workspace_id: child,
5541                }),
5542            )
5543            .await
5544            .expect("branch");
5545        let failed = repository
5546            .run(
5547                GitCommand::Switch {
5548                    branch: "recover-run".to_owned(),
5549                    create: false,
5550                },
5551                generation(2),
5552                &TestExecutor::failing(),
5553            )
5554            .await;
5555        assert!(matches!(failed, Err(GitCompatRunError::Executor(_))));
5556        let pending = repository
5557            .pending_transition()
5558            .await
5559            .expect("pending transition")
5560            .expect("durable pending transition");
5561        let executor = TestExecutor::returning(GitFilesystemResult::Applied {
5562            tree: Some(tree(1)),
5563            tracked_paths: None,
5564        });
5565        assert!(
5566            repository
5567                .resume(&executor)
5568                .await
5569                .expect("resume")
5570                .is_some()
5571        );
5572        assert!(
5573            repository
5574                .pending_transition()
5575                .await
5576                .expect("pending cleared")
5577                .is_none()
5578        );
5579        assert_eq!(
5580            executor
5581                .operations
5582                .lock()
5583                .expect("operation lock")
5584                .as_slice(),
5585            &[pending.id.operation_id()]
5586        );
5587    }
5588
5589    #[tokio::test]
5590    async fn conflicted_merge_can_continue_or_abort_without_a_second_sequencer() {
5591        async fn pending_merge(
5592            repository: &GitCompatRepository<MemoryGitCompatStore>,
5593            child: WorkspaceId,
5594        ) {
5595            repository
5596                .run(
5597                    GitCommand::Branch {
5598                        create: Some("feature".to_owned()),
5599                    },
5600                    generation(1),
5601                    &TestExecutor::returning(GitFilesystemResult::Forked {
5602                        workspace_id: child,
5603                    }),
5604                )
5605                .await
5606                .expect("feature branch");
5607            assert!(matches!(
5608                repository
5609                    .run(
5610                        GitCommand::Merge {
5611                            branch: "feature".to_owned(),
5612                        },
5613                        generation(2),
5614                        &TestExecutor::failing(),
5615                    )
5616                    .await,
5617                Err(GitCompatRunError::Executor(_))
5618            ));
5619        }
5620
5621        let child = WorkspaceId::derive(
5622            [9; 16],
5623            &WorkspaceName::new("merge-control").expect("valid workspace name"),
5624        );
5625        let continued = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
5626        pending_merge(&continued, child).await;
5627        assert_eq!(
5628            continued
5629                .run(
5630                    GitCommand::Add {
5631                        paths: vec!["conflicted.txt".to_owned()],
5632                    },
5633                    generation(3),
5634                    &TestExecutor::returning(GitFilesystemResult::Applied {
5635                        tree: None,
5636                        tracked_paths: None,
5637                    }),
5638                )
5639                .await
5640                .expect("stage conflict resolution"),
5641            GitCommandOutput::NoOp
5642        );
5643        assert!(
5644            continued
5645                .pending_transition()
5646                .await
5647                .expect("merge remains pending after add")
5648                .is_some()
5649        );
5650        assert!(matches!(
5651            continued
5652                .run(
5653                    GitCommand::MergeContinue,
5654                    generation(3),
5655                    &TestExecutor::returning(GitFilesystemResult::Applied {
5656                        tree: None,
5657                        tracked_paths: None,
5658                    }),
5659                )
5660                .await
5661                .expect("continue merge"),
5662            GitCommandOutput::Committed(GitCommit { tree: GitTreeRef::Exact(tree), .. })
5663                if tree.generation == generation(3)
5664        ));
5665        assert!(
5666            continued
5667                .pending_transition()
5668                .await
5669                .expect("continued state")
5670                .is_none()
5671        );
5672
5673        let aborted = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
5674        pending_merge(&aborted, child).await;
5675        let executor = TestExecutor::returning(GitFilesystemResult::Applied {
5676            tree: Some(tree(2)),
5677            tracked_paths: None,
5678        });
5679        assert!(matches!(
5680            aborted
5681                .run(GitCommand::MergeAbort, generation(4), &executor)
5682                .await
5683                .expect("abort merge"),
5684            GitCommandOutput::Filesystem(GitFilesystemResult::Applied { .. })
5685        ));
5686        assert!(
5687            aborted
5688                .pending_transition()
5689                .await
5690                .expect("aborted state")
5691                .is_none()
5692        );
5693    }
5694
5695    #[tokio::test]
5696    async fn capture_omits_newly_ignored_paths_but_keeps_tracked_descendants() {
5697        let fs = Fs::memory();
5698        let workspace = fs
5699            .create_workspace("git-capture-policy")
5700            .await
5701            .expect("workspace");
5702        let mut transaction = workspace
5703            .begin_transaction(IdempotencyKey::new())
5704            .await
5705            .expect("transaction");
5706        transaction
5707            .create_dir_all("/target/nested")
5708            .await
5709            .expect("directories");
5710        transaction
5711            .write_text("/target/drop.bin", "drop")
5712            .await
5713            .expect("ignored file");
5714        transaction
5715            .write_text("/target/nested/keep.txt", "keep")
5716            .await
5717            .expect("tracked file");
5718        transaction.commit().await.expect("commit fixture");
5719        let captured = capture_git_compatible_generation(
5720            &workspace,
5721            &GitIgnorePolicy::parse("target/\n"),
5722            &BTreeSet::from(["target/nested/keep.txt".to_owned()]),
5723            OperationId::from_bytes([0x31; 16]),
5724        )
5725        .await
5726        .expect("capture");
5727        assert!(captured.generation.stat("/target/drop.bin").await.is_err());
5728        assert!(
5729            captured
5730                .generation
5731                .stat("/target/nested/keep.txt")
5732                .await
5733                .is_ok()
5734        );
5735        assert_eq!(
5736            captured.tracked_paths,
5737            BTreeSet::from(["target/nested/keep.txt".to_owned()])
5738        );
5739        let recovered = capture_git_compatible_generation(
5740            &workspace,
5741            &GitIgnorePolicy::parse("target/\n"),
5742            &BTreeSet::from(["target/nested/keep.txt".to_owned()]),
5743            OperationId::from_bytes([0x31; 16]),
5744        )
5745        .await
5746        .expect("recover capture");
5747        assert_eq!(recovered.generation.id(), captured.generation.id());
5748    }
5749
5750    #[tokio::test]
5751    async fn capture_at_generation_excludes_later_workspace_updates() {
5752        let fs = Fs::memory();
5753        let workspace = fs
5754            .create_workspace("git-capture-pinned-generation")
5755            .await
5756            .expect("workspace");
5757        let mut initial = workspace
5758            .begin_transaction(IdempotencyKey::from_bytes([0x35; 16]))
5759            .await
5760            .expect("initial transaction");
5761        initial
5762            .write_text("/published.txt", "published")
5763            .await
5764            .expect("published file");
5765        let TransactionCommit::Committed(published) =
5766            initial.commit().await.expect("initial commit")
5767        else {
5768            panic!("initial transaction did not commit");
5769        };
5770        let mut later = workspace
5771            .begin_transaction(IdempotencyKey::from_bytes([0x36; 16]))
5772            .await
5773            .expect("later transaction");
5774        later
5775            .write_text("/later.txt", "later")
5776            .await
5777            .expect("later file");
5778        later.commit().await.expect("later commit");
5779
5780        let captured = capture_git_compatible_generation_at(
5781            &workspace,
5782            published.id(),
5783            &GitIgnorePolicy::default(),
5784            &BTreeSet::new(),
5785            OperationId::from_bytes([0x37; 16]),
5786        )
5787        .await
5788        .expect("pinned capture");
5789        assert!(captured.generation.stat("/published.txt").await.is_ok());
5790        assert!(captured.generation.stat("/later.txt").await.is_err());
5791    }
5792
5793    #[tokio::test]
5794    async fn capture_preserves_metadata_and_hard_links_through_public_fork_primitives() {
5795        let fs = Fs::memory();
5796        let workspace = fs
5797            .create_workspace("git-capture-records")
5798            .await
5799            .expect("workspace");
5800        let mut transaction = workspace
5801            .begin_transaction(IdempotencyKey::from_bytes([0x41; 16]))
5802            .await
5803            .expect("transaction");
5804        transaction
5805            .write_text("/source.txt", "linked")
5806            .await
5807            .expect("source");
5808        transaction
5809            .set_metadata(
5810                "/source.txt",
5811                FileMetadata {
5812                    posix_mode: MetadataField::Value(0o100_640),
5813                    ..FileMetadata::default()
5814                },
5815            )
5816            .await
5817            .expect("metadata");
5818        transaction
5819            .hard_link("/source.txt", "/linked.txt")
5820            .await
5821            .expect("hard link");
5822        transaction
5823            .create_dir_all("/ignored")
5824            .await
5825            .expect("ignored directory");
5826        transaction
5827            .write_text("/ignored/drop.txt", "drop")
5828            .await
5829            .expect("ignored file");
5830        transaction.commit().await.expect("commit fixture");
5831
5832        let captured = capture_git_compatible_generation(
5833            &workspace,
5834            &GitIgnorePolicy::parse("ignored/\n"),
5835            &BTreeSet::new(),
5836            OperationId::from_bytes([0x42; 16]),
5837        )
5838        .await
5839        .expect("capture");
5840        let source = captured
5841            .generation
5842            .stat("/source.txt")
5843            .await
5844            .expect("source stat");
5845        let linked = captured
5846            .generation
5847            .stat("/linked.txt")
5848            .await
5849            .expect("link stat");
5850        assert_eq!(source.file_id, linked.file_id);
5851        assert_eq!(source.link_count, 2);
5852        assert_eq!(source.metadata.posix_mode, Some(0o100_640));
5853        assert!(captured.generation.stat("/ignored").await.is_err());
5854    }
5855
5856    #[tokio::test]
5857    async fn exact_capture_inherits_nested_ignore_rules_with_local_negation() {
5858        let fs = Fs::memory();
5859        let workspace = fs
5860            .create_workspace("git-capture-nested-policy")
5861            .await
5862            .expect("workspace");
5863        let mut transaction = workspace
5864            .begin_transaction(IdempotencyKey::from_bytes([0x49; 16]))
5865            .await
5866            .expect("transaction");
5867        transaction.create_dir_all("/src").await.expect("directory");
5868        transaction
5869            .write_text("/src/.gitignore", "*.tmp\n!keep.tmp\n")
5870            .await
5871            .expect("nested policy");
5872        transaction
5873            .write_text("/src/drop.tmp", "drop")
5874            .await
5875            .expect("ignored file");
5876        transaction
5877            .write_text("/src/keep.tmp", "keep")
5878            .await
5879            .expect("negated file");
5880        transaction
5881            .write_text("/outside.tmp", "outside")
5882            .await
5883            .expect("outside file");
5884        transaction.commit().await.expect("fixture");
5885
5886        let captured = capture_git_compatible_generation(
5887            &workspace,
5888            &GitIgnorePolicy::default(),
5889            &BTreeSet::new(),
5890            OperationId::from_bytes([0x4a; 16]),
5891        )
5892        .await
5893        .expect("capture");
5894        assert!(captured.generation.stat("/src/drop.tmp").await.is_err());
5895        assert!(captured.generation.stat("/src/keep.tmp").await.is_ok());
5896        assert!(captured.generation.stat("/outside.tmp").await.is_ok());
5897    }
5898
5899    #[tokio::test]
5900    async fn diff_uses_nested_ignores_without_creating_a_capture_workspace() {
5901        let fs = Fs::memory();
5902        let workspace = fs
5903            .create_workspace("git-diff-policy")
5904            .await
5905            .expect("workspace");
5906        let before = workspace.head().await.expect("baseline");
5907        let mut transaction = workspace
5908            .begin_transaction(IdempotencyKey::from_bytes([0x4b; 16]))
5909            .await
5910            .expect("transaction");
5911        transaction.create_dir_all("/src").await.expect("directory");
5912        transaction
5913            .write_text("/src/.gitignore", "*.tmp\n!keep.tmp\n")
5914            .await
5915            .expect("ignore policy");
5916        transaction
5917            .write_text("/src/drop.tmp", "drop")
5918            .await
5919            .expect("ignored");
5920        transaction
5921            .write_text("/src/keep.tmp", "keep")
5922            .await
5923            .expect("kept");
5924        transaction.commit().await.expect("commit");
5925        let after = workspace.head().await.expect("changed generation");
5926
5927        let counts = git_compatible_diff_counts(
5928            &before,
5929            &after,
5930            &BTreeSet::new(),
5931            100,
5932            &CancellationToken::new(),
5933        )
5934        .await
5935        .expect("read-only diff");
5936        assert_eq!(counts.binding_changes, 2);
5937        assert_eq!(counts.file_changes, 2);
5938        assert_eq!(
5939            workspace.head().await.expect("unchanged head").id(),
5940            after.id()
5941        );
5942
5943        let tracked_counts = git_compatible_diff_counts(
5944            &before,
5945            &after,
5946            &BTreeSet::from(["src/drop.tmp".to_owned()]),
5947            100,
5948            &CancellationToken::new(),
5949        )
5950        .await
5951        .expect("tracked override");
5952        assert_eq!(tracked_counts.binding_changes, 3);
5953        assert_eq!(tracked_counts.file_changes, 3);
5954    }
5955
5956    #[tokio::test]
5957    async fn diff_uses_the_source_ignore_policy_for_deleted_paths() {
5958        let fs = Fs::memory();
5959        let workspace = fs
5960            .create_workspace("git-diff-deleted-policy")
5961            .await
5962            .expect("workspace");
5963        let mut initial = workspace
5964            .begin_transaction(IdempotencyKey::from_bytes([0x4c; 16]))
5965            .await
5966            .expect("initial transaction");
5967        initial
5968            .write_text("/drop.tmp", "old")
5969            .await
5970            .expect("source file");
5971        initial.commit().await.expect("initial commit");
5972        let before = workspace.head().await.expect("before");
5973
5974        let mut changed = workspace
5975            .begin_transaction(IdempotencyKey::from_bytes([0x4d; 16]))
5976            .await
5977            .expect("changed transaction");
5978        changed
5979            .write_text("/.gitignore", "*.tmp\n")
5980            .await
5981            .expect("new ignore policy");
5982        changed.remove("/drop.tmp").await.expect("delete old file");
5983        changed.commit().await.expect("changed commit");
5984        let after = workspace.head().await.expect("after");
5985
5986        let counts = git_compatible_diff_counts(
5987            &before,
5988            &after,
5989            &BTreeSet::new(),
5990            100,
5991            &CancellationToken::new(),
5992        )
5993        .await
5994        .expect("diff");
5995        assert_eq!(counts.binding_changes, 2);
5996        assert_eq!(counts.file_changes, 2);
5997    }
5998
5999    #[tokio::test]
6000    async fn ignored_ancestor_cannot_be_reincluded_by_nested_negation() {
6001        let fs = Fs::memory();
6002        let workspace = fs
6003            .create_workspace("git-ignored-parent")
6004            .await
6005            .expect("workspace");
6006        let before = workspace.head().await.expect("before");
6007        let mut transaction = workspace
6008            .begin_transaction(IdempotencyKey::from_bytes([0x4e; 16]))
6009            .await
6010            .expect("transaction");
6011        transaction
6012            .write_text("/.gitignore", "build/\n")
6013            .await
6014            .expect("root ignore");
6015        transaction
6016            .create_dir_all("/build")
6017            .await
6018            .expect("directory");
6019        transaction
6020            .write_text("/build/.gitignore", "!keep.txt\n")
6021            .await
6022            .expect("nested negation");
6023        transaction
6024            .write_text("/build/keep.txt", "ignored")
6025            .await
6026            .expect("file");
6027        transaction.commit().await.expect("commit");
6028        let after = workspace.head().await.expect("after");
6029
6030        let counts = git_compatible_diff_counts(
6031            &before,
6032            &after,
6033            &BTreeSet::new(),
6034            100,
6035            &CancellationToken::new(),
6036        )
6037        .await
6038        .expect("diff");
6039        assert_eq!(counts.file_changes, 1, "only root .gitignore is eligible");
6040        assert_eq!(counts.binding_changes, 1);
6041        let captured = capture_git_compatible_generation(
6042            &workspace,
6043            &GitIgnorePolicy::parse("build/\n"),
6044            &BTreeSet::new(),
6045            OperationId::from_bytes([0x4f; 16]),
6046        )
6047        .await
6048        .expect("capture");
6049        assert!(captured.generation.stat("/build/keep.txt").await.is_err());
6050    }
6051
6052    #[tokio::test]
6053    async fn incremental_capture_honors_unchanged_nested_ignore() {
6054        let fs = Fs::memory();
6055        let workspace = fs
6056            .create_workspace("git-incremental-nested")
6057            .await
6058            .expect("workspace");
6059        let mut initial = workspace
6060            .begin_transaction(IdempotencyKey::from_bytes([0x65; 16]))
6061            .await
6062            .expect("transaction");
6063        initial.create_dir_all("/src").await.expect("directory");
6064        initial
6065            .write_text("/src/.gitignore", "*.tmp\n")
6066            .await
6067            .expect("ignore");
6068        initial
6069            .write_text("/src/kept.txt", "before")
6070            .await
6071            .expect("kept");
6072        let TransactionCommit::Committed(previous_live) = initial.commit().await.expect("commit")
6073        else {
6074            panic!("initial commit did not publish");
6075        };
6076        let previous_capture = capture_git_compatible_generation(
6077            &workspace,
6078            &GitIgnorePolicy::default(),
6079            &BTreeSet::new(),
6080            OperationId::from_bytes([0x66; 16]),
6081        )
6082        .await
6083        .expect("capture");
6084        let mut changed = workspace
6085            .begin_transaction(IdempotencyKey::from_bytes([0x67; 16]))
6086            .await
6087            .expect("transaction");
6088        changed
6089            .write_text("/src/new.tmp", "ignored")
6090            .await
6091            .expect("ignored");
6092        changed
6093            .write_text("/src/kept.txt", "after")
6094            .await
6095            .expect("edit");
6096        changed.commit().await.expect("commit");
6097        let captured = capture_git_compatible_generation_incremental(
6098            &workspace,
6099            &previous_live,
6100            &previous_capture.generation,
6101            &GitIgnorePolicy::default(),
6102            &previous_capture.tracked_paths,
6103            OperationId::from_bytes([0x68; 16]),
6104        )
6105        .await
6106        .expect("incremental capture");
6107        assert!(captured.generation.stat("/src/new.tmp").await.is_err());
6108        assert_eq!(
6109            captured
6110                .generation
6111                .read("/src/kept.txt", 64)
6112                .await
6113                .expect("kept")
6114                .as_ref(),
6115            b"after"
6116        );
6117        assert!(!captured.tracked_paths.contains("src/new.tmp"));
6118    }
6119
6120    #[tokio::test]
6121    async fn newly_ignored_merge_paths_exclude_tracked_changes() {
6122        let fs = Fs::memory();
6123        let workspace = fs
6124            .create_workspace("git-ignored-merge-paths")
6125            .await
6126            .expect("workspace");
6127        let mut initial = workspace
6128            .begin_transaction(IdempotencyKey::from_bytes([0x70; 16]))
6129            .await
6130            .expect("initial transaction");
6131        initial.create_dir_all("/sub").await.expect("subdirectory");
6132        initial
6133            .write_text("/sub/.gitignore", "*.tmp\n")
6134            .await
6135            .expect("nested ignore");
6136        initial.commit().await.expect("initial commit");
6137        let before = workspace.head().await.expect("before");
6138        workspace
6139            .write_text("/scratch.tmp", "child")
6140            .await
6141            .expect("child change");
6142        workspace
6143            .write_text("/sub/cache.tmp", "child")
6144            .await
6145            .expect("nested child change");
6146        let mut build = workspace
6147            .begin_transaction(IdempotencyKey::from_bytes([0x71; 16]))
6148            .await
6149            .expect("build transaction");
6150        build
6151            .create_dir_all("/build")
6152            .await
6153            .expect("build directory");
6154        build
6155            .write_text("/build/output.bin", "generated")
6156            .await
6157            .expect("generated file");
6158        build.commit().await.expect("build commit");
6159        let after = workspace.head().await.expect("after");
6160        let changes = before
6161            .diff_to(&after, 16)
6162            .await
6163            .expect("diff")
6164            .changed_paths(16)
6165            .await
6166            .expect("changed paths");
6167        let policy = GitIgnorePolicy::parse("*.tmp\nbuild/\n");
6168        assert_eq!(
6169            policy
6170                .newly_ignored_paths_at(&after, &changes, &BTreeSet::new())
6171                .await
6172                .expect("untracked policy"),
6173            BTreeSet::from([
6174                "/scratch.tmp".to_owned(),
6175                "/sub/cache.tmp".to_owned(),
6176                "/build/output.bin".to_owned(),
6177            ])
6178        );
6179        assert!(
6180            policy
6181                .newly_ignored_paths_at(
6182                    &after,
6183                    &changes,
6184                    &BTreeSet::from([
6185                        "scratch.tmp".to_owned(),
6186                        "sub/cache.tmp".to_owned(),
6187                        "build/output.bin".to_owned(),
6188                    ]),
6189                )
6190                .await
6191                .expect("tracked policy")
6192                .is_empty()
6193        );
6194    }
6195
6196    #[tokio::test]
6197    async fn filtered_capture_retry_keeps_original_fork_generation() {
6198        let fs = Fs::memory();
6199        let workspace = fs
6200            .create_workspace("git-capture-retry")
6201            .await
6202            .expect("workspace");
6203        let mut transaction = workspace
6204            .begin_transaction(IdempotencyKey::from_bytes([0x69; 16]))
6205            .await
6206            .expect("transaction");
6207        transaction
6208            .write_text("/keep.txt", "keep")
6209            .await
6210            .expect("kept");
6211        transaction
6212            .write_text("/drop.tmp", "drop")
6213            .await
6214            .expect("ignored");
6215        transaction.commit().await.expect("commit");
6216        let operation_id = OperationId::from_bytes([0x6a; 16]);
6217        let policy = GitIgnorePolicy::parse("*.tmp\n");
6218        let first =
6219            capture_git_compatible_generation(&workspace, &policy, &BTreeSet::new(), operation_id)
6220                .await
6221                .expect("first capture");
6222        let retry =
6223            capture_git_compatible_generation(&workspace, &policy, &BTreeSet::new(), operation_id)
6224                .await
6225                .expect("retry");
6226        assert_eq!(first.generation.id(), retry.generation.id());
6227        assert_eq!(first.initial_generation, retry.initial_generation);
6228        assert_ne!(first.initial_generation, Some(first.generation.id()));
6229    }
6230
6231    #[tokio::test]
6232    async fn filtered_commit_completes_and_clears_its_transition() {
6233        let fs = Fs::memory();
6234        let workspace = fs
6235            .create_workspace("git-filtered-commit")
6236            .await
6237            .expect("workspace");
6238        let mut transaction = workspace
6239            .begin_transaction(IdempotencyKey::from_bytes([0x6b; 16]))
6240            .await
6241            .expect("transaction");
6242        transaction
6243            .write_text("/keep.txt", "keep")
6244            .await
6245            .expect("kept");
6246        transaction
6247            .write_text("/drop.tmp", "drop")
6248            .await
6249            .expect("ignored");
6250        transaction.commit().await.expect("commit");
6251        let live = workspace.head().await.expect("live");
6252        let repository = GitCompatRepository::new(workspace.id(), MemoryGitCompatStore::new());
6253        let GitCommandOutput::Prepared {
6254            transition,
6255            action: GitFilesystemAction::CaptureCommit { .. },
6256        } = repository
6257            .execute(
6258                GitCommand::Commit {
6259                    message: "filtered".to_owned(),
6260                    author: "agent".to_owned(),
6261                    authored_at_seconds: 1,
6262                },
6263                live.id(),
6264            )
6265            .await
6266            .expect("prepare")
6267        else {
6268            panic!("expected capture transition");
6269        };
6270        let captured = capture_git_compatible_generation(
6271            &workspace,
6272            &GitIgnorePolicy::parse("*.tmp\n"),
6273            &BTreeSet::new(),
6274            transition.operation_id(),
6275        )
6276        .await
6277        .expect("capture");
6278        let proof = captured
6279            .verified_proof(transition.operation_id())
6280            .await
6281            .expect("verified proof");
6282        let result = GitFilesystemResult::Captured {
6283            tree: GitTreeRef::exact(
6284                captured.generation.workspace().id(),
6285                captured.generation.id(),
6286            ),
6287            tracked_paths: captured.tracked_paths,
6288            proof,
6289        };
6290        let GitCommandOutput::Committed(commit) = repository
6291            .complete_transition_result(transition, &result)
6292            .await
6293            .expect("complete")
6294        else {
6295            panic!("expected commit");
6296        };
6297        assert!(
6298            repository
6299                .pending_transition()
6300                .await
6301                .expect("pending")
6302                .is_none()
6303        );
6304        assert!(commit.tracked_paths.contains("keep.txt"));
6305        assert!(!commit.tracked_paths.contains("drop.tmp"));
6306
6307        let mut ignored_only = workspace
6308            .begin_transaction(IdempotencyKey::from_bytes([0x6c; 16]))
6309            .await
6310            .expect("transaction");
6311        ignored_only
6312            .write_text("/new.tmp", "not committed")
6313            .await
6314            .expect("ignored file");
6315        ignored_only.commit().await.expect("workspace change");
6316        let live_after = workspace.head().await.expect("live after ignored change");
6317        let GitCommandOutput::Prepared { transition, .. } = repository
6318            .execute(
6319                GitCommand::Commit {
6320                    message: "ignored only".to_owned(),
6321                    author: "agent".to_owned(),
6322                    authored_at_seconds: 2,
6323                },
6324                live_after.id(),
6325            )
6326            .await
6327            .expect("prepare ignored-only commit")
6328        else {
6329            panic!("expected prepared transition");
6330        };
6331        let ignored_capture = capture_git_compatible_generation_incremental(
6332            &workspace,
6333            &live,
6334            &captured.generation,
6335            &GitIgnorePolicy::parse("*.tmp\n"),
6336            &commit.tracked_paths,
6337            transition.operation_id(),
6338        )
6339        .await
6340        .expect("ignored-only capture");
6341        assert_eq!(ignored_capture.generation.id(), captured.generation.id());
6342        assert!(matches!(
6343            repository
6344                .complete_transition_result(
6345                    transition,
6346                    &GitFilesystemResult::Captured {
6347                        tree: GitTreeRef::exact(
6348                            ignored_capture.generation.workspace_id(),
6349                            ignored_capture.generation.id(),
6350                        ),
6351                        tracked_paths: ignored_capture.tracked_paths,
6352                        proof: None,
6353                    },
6354                )
6355                .await,
6356            Err(GitCompatError::NothingToCommit)
6357        ));
6358        assert!(
6359            repository
6360                .pending_transition()
6361                .await
6362                .expect("pending")
6363                .is_none()
6364        );
6365    }
6366
6367    #[tokio::test]
6368    async fn incremental_capture_applies_only_changed_eligible_paths() {
6369        let fs = Fs::memory();
6370        let workspace = fs
6371            .create_workspace("git-capture-incremental")
6372            .await
6373            .expect("workspace");
6374        let mut transaction = workspace
6375            .begin_transaction(IdempotencyKey::from_bytes([0x51; 16]))
6376            .await
6377            .expect("transaction");
6378        transaction
6379            .write_text("/kept.txt", "before")
6380            .await
6381            .expect("tracked file");
6382        transaction
6383            .write_text("/unchanged.txt", "same")
6384            .await
6385            .expect("unchanged file");
6386        transaction
6387            .write_text("/previous.tmp", "ignored before capture")
6388            .await
6389            .expect("ignored fixture");
6390        let TransactionCommit::Committed(previous_live) =
6391            transaction.commit().await.expect("base commit")
6392        else {
6393            panic!("base fixture did not commit");
6394        };
6395        let policy = GitIgnorePolicy::parse("*.tmp\n");
6396        let previous_capture = capture_git_compatible_generation(
6397            &workspace,
6398            &policy,
6399            &BTreeSet::new(),
6400            OperationId::from_bytes([0x52; 16]),
6401        )
6402        .await
6403        .expect("base capture");
6404        let state_root = tempfile::tempdir().expect("lineage state root");
6405        let state_store = crate::LocalCoreStateStore::new(state_root.path());
6406        let graph = crate::WorkspaceGraph::new(state_store.clone());
6407        previous_capture
6408            .authenticated_proof(OperationId::from_bytes([0x52; 16]), &graph)
6409            .await
6410            .expect("durable first capture lineage")
6411            .expect("filtered first capture proof");
6412
6413        let mut transaction = workspace
6414            .begin_transaction(IdempotencyKey::from_bytes([0x53; 16]))
6415            .await
6416            .expect("transaction");
6417        transaction
6418            .write_text("/kept.txt", "after")
6419            .await
6420            .expect("edit");
6421        transaction
6422            .write_text("/new.tmp", "ignored")
6423            .await
6424            .expect("ignored addition");
6425        transaction.commit().await.expect("live edit");
6426
6427        let captured = capture_git_compatible_generation_incremental(
6428            &workspace,
6429            &previous_live,
6430            &previous_capture.generation,
6431            &policy,
6432            &previous_capture.tracked_paths,
6433            OperationId::from_bytes([0x54; 16]),
6434        )
6435        .await
6436        .expect("incremental capture");
6437        captured
6438            .authenticated_proof(OperationId::from_bytes([0x54; 16]), &graph)
6439            .await
6440            .expect("durable incremental lineage")
6441            .expect("filtered incremental proof");
6442        let lineage = graph
6443            .resolve(captured.generation.workspace_id())
6444            .await
6445            .expect("reopen incremental lineage");
6446        assert_eq!(
6447            lineage.parent_workspace_id,
6448            Some(previous_capture.generation.workspace_id()),
6449            "incremental capture must fork from the prior filtered capture"
6450        );
6451        let reopened = crate::DistributedFs::new(fs.clone(), state_store)
6452            .workspace(captured.generation.workspace_id())
6453            .await
6454            .expect("reopen filtered capture from durable lineage");
6455        reopened
6456            .generation(captured.generation.id())
6457            .await
6458            .expect("reopen captured generation");
6459        assert_eq!(
6460            captured
6461                .generation
6462                .read("/kept.txt", 64)
6463                .await
6464                .expect("edited file")
6465                .as_ref(),
6466            b"after"
6467        );
6468        assert_eq!(
6469            captured
6470                .generation
6471                .read("/unchanged.txt", 64)
6472                .await
6473                .expect("unchanged file")
6474                .as_ref(),
6475            b"same"
6476        );
6477        assert!(captured.generation.stat("/new.tmp").await.is_err());
6478        assert!(captured.generation.stat("/previous.tmp").await.is_err());
6479        assert_eq!(
6480            captured.tracked_paths,
6481            BTreeSet::from(["kept.txt".to_owned(), "unchanged.txt".to_owned()])
6482        );
6483    }
6484
6485    #[tokio::test]
6486    async fn nested_gitignore_change_forces_exact_capture() {
6487        let fs = Fs::memory();
6488        let workspace = fs
6489            .create_workspace("git-capture-nested-ignore")
6490            .await
6491            .expect("workspace");
6492        let mut transaction = workspace
6493            .begin_transaction(IdempotencyKey::from_bytes([0x61; 16]))
6494            .await
6495            .expect("transaction");
6496        transaction.create_dir_all("/src").await.expect("directory");
6497        transaction
6498            .write_text("/src/ignored.txt", "present before ignore")
6499            .await
6500            .expect("file");
6501        let TransactionCommit::Committed(previous_live) =
6502            transaction.commit().await.expect("base commit")
6503        else {
6504            panic!("base fixture did not commit");
6505        };
6506        let previous_capture = capture_git_compatible_generation(
6507            &workspace,
6508            &GitIgnorePolicy::default(),
6509            &BTreeSet::new(),
6510            OperationId::from_bytes([0x62; 16]),
6511        )
6512        .await
6513        .expect("base capture");
6514
6515        let mut transaction = workspace
6516            .begin_transaction(IdempotencyKey::from_bytes([0x63; 16]))
6517            .await
6518            .expect("transaction");
6519        transaction
6520            .write_text("/src/.gitignore", "ignored.txt\n")
6521            .await
6522            .expect("nested ignore");
6523        transaction.commit().await.expect("live edit");
6524
6525        let captured = capture_git_compatible_generation_incremental(
6526            &workspace,
6527            &previous_live,
6528            &previous_capture.generation,
6529            &GitIgnorePolicy::parse("src/ignored.txt\n"),
6530            &BTreeSet::new(),
6531            OperationId::from_bytes([0x64; 16]),
6532        )
6533        .await
6534        .expect("capture");
6535        assert!(captured.generation.stat("/src/ignored.txt").await.is_err());
6536        assert!(captured.generation.stat("/src/.gitignore").await.is_ok());
6537    }
6538
6539    #[tokio::test]
6540    async fn tree_walk_and_grep_are_generation_bound_and_portable() {
6541        let fs = crate::Fs::memory();
6542        let workspace = fs
6543            .create_workspace("git-inspection")
6544            .await
6545            .expect("workspace");
6546        let mut transaction = workspace
6547            .begin_transaction(IdempotencyKey::from_bytes([0x32; 16]))
6548            .await
6549            .expect("transaction");
6550        transaction.create_dir_all("/src").await.expect("directory");
6551        transaction
6552            .write_text("/src/a.txt", "one\nneedle\n")
6553            .await
6554            .expect("first file");
6555        transaction
6556            .write_text("/src/b.txt", "needle two\n")
6557            .await
6558            .expect("second file");
6559        transaction
6560            .write_text("/src/large.txt", "needle is too large")
6561            .await
6562            .expect("large file");
6563        transaction
6564            .write(
6565                "/src/binary.bin",
6566                bytes::Bytes::from_static(b"needle\xffbinary"),
6567            )
6568            .await
6569            .expect("binary file");
6570        let TransactionCommit::Committed(generation) = transaction.commit().await.expect("commit")
6571        else {
6572            panic!("fixture did not commit");
6573        };
6574        let entries = walk_git_tree(&generation, Some("src"), 10)
6575            .await
6576            .expect("walk");
6577        assert_eq!(
6578            entries
6579                .iter()
6580                .map(|entry| entry.path.as_str())
6581                .collect::<Vec<_>>(),
6582            vec!["src/a.txt", "src/b.txt", "src/binary.bin", "src/large.txt"]
6583        );
6584        let result = grep_git_generation(&generation, "needle", Some("src"), 10, 16, 10)
6585            .await
6586            .expect("grep");
6587        assert!(result.truncated);
6588        assert_eq!(result.matches.len(), 2);
6589        assert_eq!(result.matches[0].line, 2);
6590        assert_eq!(result.matches[1].line, 1);
6591
6592        let single = grep_git_generation(&generation, "needle", Some("src/a.txt"), 1, 16, 10)
6593            .await
6594            .expect("grep one file");
6595        assert!(!single.truncated);
6596        assert_eq!(single.matches.len(), 1);
6597        assert_eq!(single.matches[0].path, "src/a.txt");
6598        assert_eq!(single.matches[0].line, 2);
6599    }
6600
6601    #[tokio::test]
6602    async fn patch_application_is_atomic_conflict_checked_and_idempotent() {
6603        let fs = crate::Fs::memory();
6604        let workspace = fs.create_workspace("git-patch").await.expect("workspace");
6605        let mut transaction = workspace
6606            .begin_transaction(IdempotencyKey::from_bytes([0x33; 16]))
6607            .await
6608            .expect("transaction");
6609        transaction
6610            .write_text("/a.txt", "one\nold\n")
6611            .await
6612            .expect("fixture");
6613        transaction
6614            .write_text("/gone.txt", "gone")
6615            .await
6616            .expect("no-newline fixture");
6617        transaction.commit().await.expect("commit fixture");
6618        let patch = b"--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n one\n-old\n+new\n";
6619        let operation = IdempotencyKey::from_bytes([0x34; 16]);
6620        let TransactionCommit::Committed(generation) =
6621            apply_git_patch(&workspace, patch, operation)
6622                .await
6623                .expect("apply")
6624        else {
6625            panic!("patch did not commit");
6626        };
6627        assert_eq!(
6628            generation
6629                .read("/a.txt", 1_024)
6630                .await
6631                .expect("patched file")
6632                .as_ref(),
6633            b"one\nnew\n"
6634        );
6635        assert!(matches!(
6636            apply_git_patch(&workspace, patch, operation)
6637                .await
6638                .expect("retry"),
6639            TransactionCommit::AlreadyCommitted(_)
6640        ));
6641
6642        let conflicting = b"--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n one\n-old\n+other\n";
6643        assert!(matches!(
6644            apply_git_patch(
6645                &workspace,
6646                conflicting,
6647                IdempotencyKey::from_bytes([0x35; 16])
6648            )
6649            .await,
6650            Err(GitPatchError::Conflict { .. })
6651        ));
6652
6653        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";
6654        let TransactionCommit::Committed(generation) = apply_git_patch(
6655            &workspace,
6656            add_and_delete,
6657            IdempotencyKey::from_bytes([0x36; 16]),
6658        )
6659        .await
6660        .expect("add and delete") else {
6661            panic!("add/delete patch did not commit");
6662        };
6663        assert_eq!(
6664            generation
6665                .read("/nested/new.txt", 1_024)
6666                .await
6667                .expect("created file")
6668                .as_ref(),
6669            b"made\n"
6670        );
6671        assert!(generation.stat("/gone.txt").await.is_err());
6672        assert!(matches!(
6673            parse_unified_patch(b"--- C:/outside\n+++ C:/outside\n@@ -0,0 +1 @@\n+x\n"),
6674            Err(GitPatchError::Invalid(_))
6675        ));
6676    }
6677
6678    #[tokio::test]
6679    async fn bisect_uses_durable_first_parent_generation_checkouts() {
6680        let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
6681        let mut commits = Vec::new();
6682        let mut expected = None;
6683        for byte in 1..=5 {
6684            let GitCommandOutput::Committed(commit) = repository
6685                .record_commit(
6686                    expected,
6687                    generation(byte),
6688                    BTreeSet::from(["tracked.txt".to_owned()]),
6689                    format!("commit {byte}"),
6690                    "agent".to_owned(),
6691                    i64::from(byte),
6692                )
6693                .await
6694                .expect("record commit")
6695            else {
6696                panic!("expected recorded commit");
6697            };
6698            expected = Some(commit.id);
6699            commits.push(commit.id);
6700        }
6701
6702        let GitCommandOutput::Bisect(started) = repository
6703            .run(
6704                GitCommand::Bisect {
6705                    arguments: vec!["start".to_owned(), commits[4].to_hex(), commits[0].to_hex()],
6706                },
6707                generation(5),
6708                &TestExecutor::returning(GitFilesystemResult::Applied {
6709                    tree: Some(tree(3)),
6710                    tracked_paths: None,
6711                }),
6712            )
6713            .await
6714            .expect("start bisect")
6715        else {
6716            panic!("expected bisect state");
6717        };
6718        assert_eq!(started.current, Some(commits[2]));
6719        assert_eq!(started.remaining, 3);
6720
6721        let GitCommandOutput::Bisect(narrowed) = repository
6722            .run(
6723                GitCommand::Bisect {
6724                    arguments: vec!["good".to_owned()],
6725                },
6726                generation(3),
6727                &TestExecutor::returning(GitFilesystemResult::Applied {
6728                    tree: Some(tree(4)),
6729                    tracked_paths: None,
6730                }),
6731            )
6732            .await
6733            .expect("mark good")
6734        else {
6735            panic!("expected narrowed bisect state");
6736        };
6737        assert_eq!(narrowed.current, Some(commits[3]));
6738
6739        let GitCommandOutput::Bisect(done) = repository
6740            .run(
6741                GitCommand::Bisect {
6742                    arguments: vec!["bad".to_owned()],
6743                },
6744                generation(4),
6745                &TestExecutor::returning(GitFilesystemResult::Applied {
6746                    tree: Some(tree(4)),
6747                    tracked_paths: None,
6748                }),
6749            )
6750            .await
6751            .expect("mark bad")
6752        else {
6753            panic!("expected completed bisect state");
6754        };
6755        assert_eq!(done.first_bad, Some(commits[3]));
6756        assert_eq!(done.remaining, 0);
6757
6758        let GitCommandOutput::Bisect(reset) = repository
6759            .run(
6760                GitCommand::Bisect {
6761                    arguments: vec!["reset".to_owned()],
6762                },
6763                generation(4),
6764                &TestExecutor::returning(GitFilesystemResult::Applied {
6765                    tree: Some(tree(5)),
6766                    tracked_paths: None,
6767                }),
6768            )
6769            .await
6770            .expect("reset bisect")
6771        else {
6772            panic!("expected reset bisect state");
6773        };
6774        assert!(!reset.active);
6775    }
6776
6777    #[tokio::test]
6778    async fn distributed_publication_records_one_idempotent_merge_commit() {
6779        let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
6780        let GitCommandOutput::Committed(initial) = repository
6781            .record_commit(
6782                None,
6783                generation(1),
6784                BTreeSet::from(["tracked.txt".to_owned()]),
6785                "initial",
6786                "parent",
6787                1,
6788            )
6789            .await
6790            .expect("initial commit")
6791        else {
6792            panic!("expected initial commit");
6793        };
6794        let source = WorkspaceId::derive(
6795            [0x51; 16],
6796            &WorkspaceName::new("publication-source").expect("workspace name"),
6797        );
6798        repository
6799            .register_branch_workspace("agents/child", source, Some(initial.id), false)
6800            .await
6801            .expect("source branch");
6802        assert!(matches!(
6803            repository
6804                .record_publication(GitPublicationRecord {
6805                    tree: tree(1),
6806                    workspace_tree: GitTreeRef::exact(source, generation(1)),
6807                    source_head: None,
6808                    tracked_paths: BTreeSet::new(),
6809                    capture_proof: None,
6810                    message: "foreign no-op".to_owned(),
6811                    author: "parent".to_owned(),
6812                    authored_at_seconds: 2,
6813                })
6814                .await,
6815            Err(GitCompatError::WorkspaceMismatch)
6816        ));
6817        let tracked = BTreeSet::from(["tracked.txt".to_owned(), "new.txt".to_owned()]);
6818        let GitCommandOutput::Committed(merged) = repository
6819            .record_publication(GitPublicationRecord {
6820                tree: tree(2),
6821                workspace_tree: tree(3),
6822                source_head: Some(initial.id),
6823                tracked_paths: tracked.clone(),
6824                capture_proof: None,
6825                message: "Merge agents/child".to_owned(),
6826                author: "parent".to_owned(),
6827                authored_at_seconds: 2,
6828            })
6829            .await
6830            .expect("publication commit")
6831        else {
6832            panic!("expected publication commit");
6833        };
6834        assert_eq!(merged.parents, vec![initial.id]);
6835        assert_eq!(merged.tracked_paths, tracked);
6836        assert_eq!(
6837            repository.tracked_paths().await.expect("tracked paths"),
6838            tracked
6839        );
6840        assert_eq!(
6841            repository
6842                .record_publication(GitPublicationRecord {
6843                    tree: tree(2),
6844                    workspace_tree: tree(3),
6845                    source_head: Some(initial.id),
6846                    tracked_paths: BTreeSet::new(),
6847                    capture_proof: None,
6848                    message: "retry".to_owned(),
6849                    author: "parent".to_owned(),
6850                    authored_at_seconds: 99,
6851                })
6852                .await
6853                .expect("exact retry"),
6854            GitCommandOutput::NoOp
6855        );
6856    }
6857
6858    #[tokio::test]
6859    async fn cached_diff_is_head_to_workspace_and_add_is_noop() {
6860        let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
6861        let GitCommandOutput::Prepared {
6862            transition,
6863            action: GitFilesystemAction::CaptureCommit { .. },
6864        } = repository
6865            .execute(
6866                GitCommand::Commit {
6867                    message: "initial".to_owned(),
6868                    author: "agent".to_owned(),
6869                    authored_at_seconds: 10,
6870                },
6871                generation(1),
6872            )
6873            .await
6874            .expect("capture commit")
6875        else {
6876            panic!("expected commit capture");
6877        };
6878        repository
6879            .complete_transition_result(
6880                transition,
6881                &GitFilesystemResult::Captured {
6882                    tree: tree(1),
6883                    tracked_paths: BTreeSet::new(),
6884                    proof: None,
6885                },
6886            )
6887            .await
6888            .expect("record commit");
6889        assert_eq!(
6890            repository
6891                .execute(
6892                    GitCommand::Add {
6893                        paths: vec![".".to_owned()],
6894                    },
6895                    generation(2),
6896                )
6897                .await
6898                .expect("add"),
6899            GitCommandOutput::NoOp
6900        );
6901        assert!(matches!(
6902            repository
6903                .execute(GitCommand::Diff { cached: true }, generation(2))
6904                .await
6905                .expect("diff"),
6906            GitCommandOutput::Prepared {
6907                action: GitFilesystemAction::Diff {
6908                    from: Some(from),
6909                    to,
6910                    tracked_paths,
6911                },
6912                ..
6913            } if from == tree(1)
6914                && to == tree(2)
6915                && tracked_paths.is_empty()
6916        ));
6917    }
6918
6919    #[tokio::test]
6920    async fn every_branch_requests_and_registers_a_distinct_workspace() {
6921        let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
6922        let GitCommandOutput::Prepared {
6923            transition,
6924            action:
6925                GitFilesystemAction::ForkBranch {
6926                    source_tree,
6927                    head,
6928                    switch,
6929                    ..
6930                },
6931        } = repository
6932            .execute(
6933                GitCommand::Switch {
6934                    branch: "feature".to_owned(),
6935                    create: true,
6936                },
6937                generation(4),
6938            )
6939            .await
6940            .expect("fork request")
6941        else {
6942            panic!("expected a core workspace fork request");
6943        };
6944        assert_eq!(source_tree, tree(4));
6945        assert!(head.is_none());
6946        assert!(switch);
6947        let child = WorkspaceId::derive(
6948            [9; 16],
6949            &WorkspaceName::new("feature").expect("valid workspace name"),
6950        );
6951        repository
6952            .complete_transition_result(
6953                transition,
6954                &GitFilesystemResult::Forked {
6955                    workspace_id: child,
6956                },
6957            )
6958            .await
6959            .expect("register branch workspace");
6960        let GitCommandOutput::Status(status) = repository
6961            .execute(GitCommand::Status, GitTreeRef::exact(child, generation(4)))
6962            .await
6963            .expect("feature status")
6964        else {
6965            panic!("expected status");
6966        };
6967        assert_eq!(status.branch, "feature");
6968    }
6969
6970    #[tokio::test]
6971    async fn argv_covers_local_history_and_rejects_transport() {
6972        let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
6973        assert!(matches!(
6974            repository
6975                .execute_argv(
6976                    &["reset".to_owned(), "--hard".to_owned(), "HEAD".to_owned()],
6977                    generation(1),
6978                    "agent",
6979                    10,
6980                )
6981                .await,
6982            Err(GitCompatError::UnbornHead)
6983        ));
6984        assert!(matches!(
6985            repository
6986                .execute_argv(&["clean".to_owned()], generation(1), "agent", 10,)
6987                .await,
6988            Err(GitCompatError::InvalidCommand(message)) if message.contains("requires -f")
6989        ));
6990        assert!(matches!(
6991            repository
6992                .execute_argv(
6993                    &["clean".to_owned(), "--dry-run".to_owned()],
6994                    generation(1),
6995                    "agent",
6996                    10,
6997                )
6998                .await,
6999            Ok(GitCommandOutput::Prepared {
7000                action: GitFilesystemAction::Clean { dry_run: true, .. },
7001                ..
7002            })
7003        ));
7004        assert!(matches!(
7005            repository
7006                .execute_argv(&["push".to_owned()], generation(1), "agent", 10,)
7007                .await,
7008            Err(GitCompatError::Unsupported { .. })
7009        ));
7010    }
7011
7012    #[test]
7013    fn argv_parser_has_an_explicit_command_conformance_matrix() {
7014        let cases = [
7015            ((&["status", "--short"] as &[&str]), GitCommand::Status),
7016            (
7017                (&["diff", "--staged"] as &[&str]),
7018                GitCommand::Diff { cached: true },
7019            ),
7020            (
7021                (&["log", "--max-count=7"] as &[&str]),
7022                GitCommand::Log { maximum: 7 },
7023            ),
7024            (
7025                (&["show", "HEAD"] as &[&str]),
7026                GitCommand::Show {
7027                    object: Some(GitObjectName("HEAD".to_owned())),
7028                },
7029            ),
7030            (
7031                (&["add", "--all"] as &[&str]),
7032                GitCommand::Add { paths: Vec::new() },
7033            ),
7034            (
7035                (&["commit", "--message=ready", "--author=agent"] as &[&str]),
7036                GitCommand::Commit {
7037                    message: "ready".to_owned(),
7038                    author: "agent".to_owned(),
7039                    authored_at_seconds: 10,
7040                },
7041            ),
7042            (
7043                (&["branch", "topic"] as &[&str]),
7044                GitCommand::Branch {
7045                    create: Some("topic".to_owned()),
7046                },
7047            ),
7048            (
7049                (&["switch", "-c", "topic"] as &[&str]),
7050                GitCommand::Switch {
7051                    branch: "topic".to_owned(),
7052                    create: true,
7053                },
7054            ),
7055            (
7056                (&["restore", "--source=HEAD", "--", "file"] as &[&str]),
7057                GitCommand::Restore {
7058                    source: Some(GitObjectName("HEAD".to_owned())),
7059                    paths: vec!["file".to_owned()],
7060                },
7061            ),
7062            (
7063                (&["reset", "--hard", "HEAD"] as &[&str]),
7064                GitCommand::Reset {
7065                    target: GitObjectName("HEAD".to_owned()),
7066                    mode: GitResetMode::Hard,
7067                },
7068            ),
7069            (
7070                (&["merge", "topic"] as &[&str]),
7071                GitCommand::Merge {
7072                    branch: "topic".to_owned(),
7073                },
7074            ),
7075            (
7076                (&["rebase", "main"] as &[&str]),
7077                GitCommand::Rebase {
7078                    branch: "main".to_owned(),
7079                },
7080            ),
7081            ((&["stash", "push"] as &[&str]), GitCommand::StashPush),
7082            ((&["stash", "pop"] as &[&str]), GitCommand::StashPop),
7083            (
7084                (&["cherry-pick", "HEAD"] as &[&str]),
7085                GitCommand::CherryPick {
7086                    object: GitObjectName("HEAD".to_owned()),
7087                },
7088            ),
7089            (
7090                (&["revert", "HEAD"] as &[&str]),
7091                GitCommand::Revert {
7092                    object: GitObjectName("HEAD".to_owned()),
7093                },
7094            ),
7095            (
7096                (&["tag", "v1", "HEAD"] as &[&str]),
7097                GitCommand::Tag {
7098                    name: Some("v1".to_owned()),
7099                    target: Some(GitObjectName("HEAD".to_owned())),
7100                    delete: false,
7101                },
7102            ),
7103            (
7104                (&["blame", "file"] as &[&str]),
7105                GitCommand::Blame {
7106                    path: "file".to_owned(),
7107                },
7108            ),
7109            (
7110                (&["grep", "needle", "src"] as &[&str]),
7111                GitCommand::Grep {
7112                    pattern: "needle".to_owned(),
7113                    path: Some("src".to_owned()),
7114                },
7115            ),
7116            (
7117                (&["clean", "-n"] as &[&str]),
7118                GitCommand::Clean { dry_run: true },
7119            ),
7120            (
7121                (&["archive", "HEAD"] as &[&str]),
7122                GitCommand::Archive {
7123                    object: Some(GitObjectName("HEAD".to_owned())),
7124                },
7125            ),
7126            (
7127                (&["bisect", "start", "bad", "good"] as &[&str]),
7128                GitCommand::Bisect {
7129                    arguments: vec!["start".to_owned(), "bad".to_owned(), "good".to_owned()],
7130                },
7131            ),
7132            (
7133                (&["checkout", "topic"] as &[&str]),
7134                GitCommand::Switch {
7135                    branch: "topic".to_owned(),
7136                    create: false,
7137                },
7138            ),
7139            (
7140                (&["checkout", "-b", "topic"] as &[&str]),
7141                GitCommand::Switch {
7142                    branch: "topic".to_owned(),
7143                    create: true,
7144                },
7145            ),
7146            (
7147                (&["checkout", "HEAD", "--", "src/lib.rs"] as &[&str]),
7148                GitCommand::Restore {
7149                    source: Some(GitObjectName("HEAD".to_owned())),
7150                    paths: vec!["src/lib.rs".to_owned()],
7151                },
7152            ),
7153            (
7154                (&["rev-parse", "--abbrev-ref", "HEAD"] as &[&str]),
7155                GitCommand::RevParse {
7156                    argument: "--abbrev-ref HEAD".to_owned(),
7157                },
7158            ),
7159            (
7160                (&["symbolic-ref", "--short", "HEAD"] as &[&str]),
7161                GitCommand::SymbolicRef { short: true },
7162            ),
7163            (
7164                (&["merge-base", "HEAD", "topic"] as &[&str]),
7165                GitCommand::MergeBase {
7166                    left: GitObjectName("HEAD".to_owned()),
7167                    right: GitObjectName("topic".to_owned()),
7168                },
7169            ),
7170            ((&["ls-files"] as &[&str]), GitCommand::LsFiles),
7171            (
7172                (&["check-ignore", "--", "target/file"] as &[&str]),
7173                GitCommand::CheckIgnore {
7174                    paths: vec!["target/file".to_owned()],
7175                },
7176            ),
7177        ];
7178        for (argv, expected) in cases {
7179            let argv = argv
7180                .iter()
7181                .map(|value| (*value).to_owned())
7182                .collect::<Vec<_>>();
7183            assert_eq!(
7184                parse_argv::<MemoryGitCompatStoreError>(&argv, "default", 10)
7185                    .expect("supported argv"),
7186                expected,
7187                "argv: {argv:?}"
7188            );
7189        }
7190        assert_eq!(
7191            parse_argv::<MemoryGitCompatStoreError>(
7192                &["add".to_owned(), "--".to_owned(), "--all".to_owned()],
7193                "default",
7194                10,
7195            )
7196            .expect("literal option-looking path"),
7197            GitCommand::Add {
7198                paths: vec!["--all".to_owned()],
7199            }
7200        );
7201    }
7202
7203    #[test]
7204    fn argv_rejects_semantically_unrepresentable_and_shell_composed_commands() {
7205        for command in [
7206            "clone",
7207            "fetch",
7208            "pull",
7209            "push",
7210            "remote",
7211            "gc",
7212            "repack",
7213            "cat-file",
7214            "hash-object",
7215            "init",
7216            "fsck",
7217            "prune",
7218            "pack-objects",
7219            "index-pack",
7220            "receive-pack",
7221            "upload-pack",
7222            "read-tree",
7223            "write-tree",
7224            "commit-tree",
7225            "update-index",
7226            "worktree",
7227            "submodule",
7228            "apply",
7229        ] {
7230            let argv = vec![command.to_owned()];
7231            assert!(
7232                matches!(
7233                    parse_argv::<MemoryGitCompatStoreError>(&argv, "agent", 10),
7234                    Err(GitCompatError::Unsupported { .. })
7235                ),
7236                "command should be explicitly unsupported: {command}"
7237            );
7238        }
7239        for argv in [
7240            vec!["status", "&&", "push"],
7241            vec!["status", "|", "cat"],
7242            vec!["status", ">result"],
7243            vec!["status", "$(push)"],
7244        ] {
7245            let argv = argv.into_iter().map(str::to_owned).collect::<Vec<_>>();
7246            assert!(matches!(
7247                parse_argv::<MemoryGitCompatStoreError>(&argv, "agent", 10),
7248                Err(GitCompatError::Unsupported { reason, .. }) if reason.contains("shell composition")
7249            ));
7250        }
7251        for argv in [
7252            vec!["status", "unexpected"],
7253            vec!["diff", "--stat"],
7254            vec!["reset", "HEAD", "path"],
7255            vec!["merge", "topic", "other"],
7256            vec!["restore", "--source", "HEAD"],
7257            vec!["clean", "-d"],
7258            vec!["clean", "-fd"],
7259            vec!["clean", "-nd"],
7260        ] {
7261            let argv = argv.into_iter().map(str::to_owned).collect::<Vec<_>>();
7262            assert!(parse_argv::<MemoryGitCompatStoreError>(&argv, "agent", 10).is_err());
7263        }
7264    }
7265
7266    #[tokio::test]
7267    async fn state_changing_filesystem_actions_are_prepared_then_completed() {
7268        let repository = GitCompatRepository::new(workspace(), MemoryGitCompatStore::new());
7269        let GitCommandOutput::Prepared {
7270            transition,
7271            action: GitFilesystemAction::CaptureCommit { .. },
7272        } = repository
7273            .execute(
7274                GitCommand::Commit {
7275                    message: "initial".to_owned(),
7276                    author: "agent".to_owned(),
7277                    authored_at_seconds: 10,
7278                },
7279                generation(1),
7280            )
7281            .await
7282            .expect("prepare commit")
7283        else {
7284            panic!("expected commit capture");
7285        };
7286        let GitCommandOutput::Committed(commit) = repository
7287            .complete_transition_result(
7288                transition,
7289                &GitFilesystemResult::Captured {
7290                    tree: tree(1),
7291                    tracked_paths: BTreeSet::new(),
7292                    proof: None,
7293                },
7294            )
7295            .await
7296            .expect("record commit")
7297        else {
7298            panic!("expected commit");
7299        };
7300        repository
7301            .register_branch_workspace("feature", workspace(), Some(commit.id), false)
7302            .await
7303            .expect("register branch");
7304        let GitCommandOutput::Prepared { transition, .. } = repository
7305            .execute(
7306                GitCommand::Switch {
7307                    branch: "feature".to_owned(),
7308                    create: false,
7309                },
7310                generation(2),
7311            )
7312            .await
7313            .expect("prepare switch")
7314        else {
7315            panic!("expected prepared switch");
7316        };
7317        assert!(matches!(
7318            repository
7319                .execute(GitCommand::Status, generation(2))
7320                .await,
7321            Err(GitCompatError::TransitionPending(id)) if id == transition
7322        ));
7323        repository
7324            .complete_transition(transition, None)
7325            .await
7326            .expect("complete switch");
7327        let GitCommandOutput::Status(status) = repository
7328            .execute(GitCommand::Status, generation(1))
7329            .await
7330            .expect("status")
7331        else {
7332            panic!("expected status");
7333        };
7334        assert_eq!(status.branch, "feature");
7335    }
7336
7337    #[test]
7338    fn ignore_policy_keeps_tracked_paths_and_honors_last_match() {
7339        let policy = GitIgnorePolicy::parse("target/\n*.log\n!important.log\n");
7340        assert!(!policy.eligible("target", true, false));
7341        assert!(!policy.eligible("target/cache.bin", false, false));
7342        assert!(!policy.eligible("nested/target/cache.bin", false, false));
7343        assert!(!policy.eligible("nested/debug.log", false, false));
7344        assert!(policy.eligible("nested/important.log", false, false));
7345        assert!(policy.eligible("target/cache", false, true));
7346    }
7347
7348    #[test]
7349    fn portable_git_names_decode_canonical_windows_names() {
7350        let bytes = "source-λ.rs"
7351            .encode_utf16()
7352            .flat_map(u16::to_le_bytes)
7353            .collect();
7354        let name = crate::kernel::LogicalName::new(
7355            crate::kernel::NameEncoding::WindowsUtf16Le,
7356            bytes,
7357            255,
7358        )
7359        .expect("valid Windows name");
7360        assert_eq!(name.unicode_text().as_deref(), Some("source-λ.rs"));
7361    }
7362}