1use 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
141pub struct GitCommitId([u8; 32]);
142
143impl GitCommitId {
144 #[must_use]
146 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
147 Self(bytes)
148 }
149
150 #[must_use]
152 pub const fn into_bytes(self) -> [u8; 32] {
153 self.0
154 }
155
156 #[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#[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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
215pub struct GitCommit {
216 pub id: GitCommitId,
218 pub tree: GitTreeRef,
220 pub workspace_tree: GitTreeRef,
223 #[serde(default)]
225 pub capture_proof: Option<GitCaptureProof>,
226 #[serde(default)]
228 pub tracked_paths: BTreeSet<String>,
229 pub parents: Vec<GitCommitId>,
231 pub author: String,
233 pub authored_at_seconds: i64,
235 pub message: String,
237}
238
239impl GitCommit {
240 #[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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
369pub struct GitBranch {
370 pub name: String,
372 pub workspace_id: WorkspaceId,
374 pub head: Option<GitCommitId>,
376 #[serde(default)]
378 pub tracked_paths: BTreeSet<String>,
379}
380
381#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
383pub struct GitGenerationRef {
384 pub workspace_id: WorkspaceId,
386 pub generation: GenerationId,
388}
389
390impl From<GitGenerationRef> for GitTreeRef {
391 fn from(value: GitGenerationRef) -> Self {
392 Self::Exact(value)
393 }
394}
395
396#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
398#[serde(tag = "kind", rename_all = "kebab-case")]
399pub enum GitTreeRef {
400 Exact(GitGenerationRef),
402 Lazy(crate::LazySnapshotRef),
404}
405
406impl GitTreeRef {
407 #[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 #[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 #[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
434pub trait IntoGitTreeRef {
436 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
454pub struct GitCompatState {
455 pub version: u32,
457 pub revision: u64,
459 pub current_branch: String,
461 pub branches: BTreeMap<String, GitBranch>,
463 pub commits: BTreeMap<GitCommitId, GitCommit>,
465 #[serde(default)]
469 pub external_parents: BTreeSet<GitCommitId>,
470 pub tags: BTreeMap<String, GitCommitId>,
472 pub reflog: Vec<Option<GitCommitId>>,
474 pub stash: Vec<GitTreeRef>,
476 #[serde(
479 default,
480 rename = "tracked_paths",
481 skip_serializing_if = "BTreeSet::is_empty"
482 )]
483 pub legacy_tracked_paths: BTreeSet<String>,
484 #[serde(default)]
486 pub pending: Option<GitPendingTransition>,
487 #[serde(default)]
489 pub bisect: Option<GitBisectState>,
490}
491
492impl GitCompatState {
493 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
538#[serde(transparent)]
539pub struct GitTransitionId(OperationId);
540
541impl GitTransitionId {
542 #[must_use]
544 pub fn new() -> Self {
545 Self(OperationId::new())
546 }
547
548 #[must_use]
550 pub const fn from_bytes(bytes: [u8; 16]) -> Self {
551 Self(OperationId::from_bytes(bytes))
552 }
553
554 #[must_use]
556 pub const fn into_bytes(self) -> [u8; 16] {
557 self.0.into_bytes()
558 }
559
560 #[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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
575#[non_exhaustive]
576pub enum GitPendingMutation {
577 NoOp,
579 CaptureCommit {
581 workspace_tree: GitTreeRef,
583 message: String,
585 author: String,
587 authored_at_seconds: i64,
589 expected_head: Option<GitCommitId>,
591 },
592 ForkBranch {
594 branch: String,
596 head: Option<GitCommitId>,
598 switch: bool,
600 },
601 Switch {
603 branch: String,
605 },
606 Reset {
608 head: GitCommitId,
610 },
611 StashPush {
613 tree: GitTreeRef,
615 },
616 StashPop {
618 tree: GitTreeRef,
620 },
621 Join {
623 source_head: Option<GitCommitId>,
625 source_branch: String,
627 rebase: bool,
629 },
630 ApplyCommit {
632 commit: GitCommitId,
634 reverse: bool,
636 },
637 Bisect {
639 state: Box<Option<GitBisectState>>,
641 result: GitBisectResult,
643 },
644}
645
646#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
648pub struct GitBisectState {
649 pub original_branch: String,
651 pub original_head: GitCommitId,
653 pub good: Option<GitCommitId>,
655 pub bad: GitCommitId,
657 pub current: Option<GitCommitId>,
659 pub skipped: BTreeSet<GitCommitId>,
661}
662
663#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
665pub struct GitBisectResult {
666 pub active: bool,
668 pub good: Option<GitCommitId>,
670 pub bad: Option<GitCommitId>,
672 pub current: Option<GitCommitId>,
674 pub remaining: u32,
676 pub first_bad: Option<GitCommitId>,
678}
679
680#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
682pub struct GitPendingTransition {
683 pub id: GitTransitionId,
685 pub action: GitFilesystemAction,
687 pub mutation: GitPendingMutation,
689}
690
691#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
693pub struct GitObjectName(pub String);
694
695#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
697pub enum GitResetMode {
698 Soft,
700 Mixed,
702 Hard,
704}
705
706#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
708#[non_exhaustive]
709pub enum GitCommand {
710 Status,
712 Diff {
714 cached: bool,
716 },
717 Log {
719 maximum: u32,
721 },
722 Show {
724 object: Option<GitObjectName>,
726 },
727 Add {
729 paths: Vec<String>,
731 },
732 Commit {
734 message: String,
736 author: String,
738 authored_at_seconds: i64,
740 },
741 Branch {
743 create: Option<String>,
745 },
746 Switch {
748 branch: String,
750 create: bool,
752 },
753 Restore {
755 source: Option<GitObjectName>,
757 paths: Vec<String>,
759 },
760 Reset {
762 target: GitObjectName,
764 mode: GitResetMode,
766 },
767 Merge {
769 branch: String,
771 },
772 MergeContinue,
774 MergeAbort,
776 Rebase {
778 branch: String,
780 },
781 StashPush,
783 StashPop,
785 CherryPick {
787 object: GitObjectName,
789 },
790 Revert {
792 object: GitObjectName,
794 },
795 Tag {
797 name: Option<String>,
799 target: Option<GitObjectName>,
801 delete: bool,
803 },
804 Blame {
806 path: String,
808 },
809 Grep {
811 pattern: String,
813 path: Option<String>,
815 },
816 Clean {
818 dry_run: bool,
820 },
821 Archive {
823 object: Option<GitObjectName>,
825 },
826 Apply {
828 patch: Vec<u8>,
830 },
831 Bisect {
833 arguments: Vec<String>,
835 },
836 RevParse {
838 argument: String,
840 },
841 SymbolicRef {
843 short: bool,
845 },
846 MergeBase {
848 left: GitObjectName,
850 right: GitObjectName,
852 },
853 LsFiles,
855 CheckIgnore {
857 paths: Vec<String>,
859 },
860}
861
862#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
864#[non_exhaustive]
865pub enum GitFilesystemAction {
866 CaptureCommit {
868 workspace_tree: GitTreeRef,
870 head_tree: Option<GitTreeRef>,
872 head_workspace_tree: Box<Option<GitTreeRef>>,
874 tracked_paths: BTreeSet<String>,
876 message: String,
878 author: String,
880 authored_at_seconds: i64,
882 expected_head: Option<GitCommitId>,
884 },
885 ForkBranch {
887 branch: String,
889 source_tree: GitTreeRef,
891 head: Option<GitCommitId>,
893 switch: bool,
895 },
896 SwitchWorkspace {
898 workspace_id: WorkspaceId,
900 },
901 Diff {
903 from: Option<GitTreeRef>,
905 to: GitTreeRef,
907 tracked_paths: BTreeSet<String>,
909 },
910 RestoreGeneration {
912 tree: GitTreeRef,
914 paths: Option<BTreeSet<String>>,
917 },
918 RestorePaths {
920 tree: GitTreeRef,
922 paths: Vec<String>,
924 },
925 Join {
927 target_tree: GitTreeRef,
929 source_workspace: WorkspaceId,
931 rebase: bool,
933 tracked_paths: BTreeSet<String>,
935 },
936 ApplyCommit {
938 commit: GitCommitId,
940 reverse: bool,
942 base: Option<GitTreeRef>,
944 source: Option<GitTreeRef>,
946 paths: BTreeSet<String>,
948 tracked_paths: BTreeSet<String>,
950 },
951 Blame {
953 path: String,
955 commits: Vec<GitCommit>,
957 },
958 Grep {
960 pattern: String,
962 path: Option<String>,
964 tree: GitTreeRef,
966 },
967 Clean {
969 dry_run: bool,
971 tree: GitTreeRef,
973 tracked_paths: BTreeSet<String>,
975 },
976 Archive {
978 tree: GitTreeRef,
980 },
981 ApplyPatch {
983 patch: Vec<u8>,
985 },
986 CheckIgnore {
988 paths: Vec<String>,
990 tree: GitTreeRef,
992 },
993}
994
995#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
997#[non_exhaustive]
998#[allow(clippy::large_enum_variant)] pub enum GitFilesystemResult {
1000 Captured {
1002 tree: GitTreeRef,
1004 tracked_paths: BTreeSet<String>,
1006 proof: Option<GitCaptureProof>,
1008 },
1009 Forked {
1011 workspace_id: WorkspaceId,
1013 },
1014 Applied {
1016 tree: Option<GitTreeRef>,
1018 tracked_paths: Option<BTreeSet<String>>,
1020 },
1021 Data {
1023 kind: String,
1025 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1042#[serde(rename_all = "kebab-case")]
1043pub enum GitDirtyState {
1044 Clean,
1046 Dirty,
1048 Unknown,
1050}
1051
1052pub trait GitFilesystemExecutor: Send + Sync {
1058 type Error: std::error::Error + Send + Sync + 'static;
1060
1061 fn validate(&self) -> impl Future<Output = Result<(), Self::Error>> + Send {
1063 async { Ok(()) }
1064 }
1065
1066 fn execute(
1068 &self,
1069 operation_id: OperationId,
1070 action: &GitFilesystemAction,
1071 ) -> impl Future<Output = Result<GitFilesystemResult, Self::Error>> + Send;
1072}
1073
1074#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1076pub struct GitStatus {
1077 pub branch: String,
1079 pub head: Option<GitCommitId>,
1081 pub workspace: GitTreeRef,
1083 pub dirty: GitDirtyState,
1085 pub all_changes_staged: bool,
1087}
1088
1089#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1091#[non_exhaustive]
1092#[allow(clippy::large_enum_variant)] pub enum GitCommandOutput {
1094 NoOp,
1096 Status(GitStatus),
1098 Commits(Vec<GitCommit>),
1100 Branches {
1102 current: String,
1104 branches: Vec<GitBranch>,
1106 },
1107 Tags(BTreeMap<String, GitCommitId>),
1109 Committed(GitCommit),
1111 Bisect(GitBisectResult),
1113 Action(GitFilesystemAction),
1115 Prepared {
1117 transition: GitTransitionId,
1119 action: GitFilesystemAction,
1121 },
1122 Filesystem(GitFilesystemResult),
1124 Text(String),
1126 Paths(Vec<String>),
1128}
1129
1130pub trait GitCompatStore: Send + Sync {
1132 type Error: std::error::Error + Send + Sync + 'static;
1134
1135 fn load(
1137 &self,
1138 workspace_id: WorkspaceId,
1139 ) -> impl Future<Output = Result<Option<GitCompatState>, Self::Error>> + Send;
1140
1141 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 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#[derive(Debug, Error)]
1177pub enum GitCompatError<E: std::error::Error + 'static> {
1178 #[error("Git compatibility store failed: {0}")]
1180 Store(E),
1181 #[error("Git compatibility state is invalid")]
1183 InvalidState,
1184 #[error("Git compatibility live tree does not belong to the selected branch workspace")]
1186 WorkspaceMismatch,
1187 #[error("invalid Git-compatible command: {0}")]
1189 InvalidCommand(String),
1190 #[error("unknown Git-compatible object '{0}'")]
1192 UnknownObject(String),
1193 #[error("Git-compatible branch '{0}' already exists")]
1195 BranchExists(String),
1196 #[error("Git-compatible HEAD is unborn")]
1198 UnbornHead,
1199 #[error("nothing to commit")]
1201 NothingToCommit,
1202 #[error("unsupported Git command '{command}': {reason}")]
1204 Unsupported {
1205 command: String,
1207 reason: String,
1209 },
1210 #[error("Git compatibility state remained contended")]
1212 Contended,
1213 #[error("Git compatibility transition {0:?} is still pending")]
1215 TransitionPending(GitTransitionId),
1216 #[error("Git compatibility transition is stale")]
1218 StaleTransition,
1219 #[error("Git compatibility transition requires a resulting generation")]
1221 MissingResultGeneration,
1222}
1223
1224#[derive(Debug, Error)]
1226pub enum GitCompatRunError<S: std::error::Error + 'static, E: std::error::Error + 'static> {
1227 #[error(transparent)]
1229 Compat(#[from] GitCompatError<S>),
1230 #[error("Git compatibility filesystem action failed: {0}")]
1232 Executor(E),
1233 #[error("Git compatibility executor returned an invalid result")]
1235 InvalidResult,
1236 #[error("Git compatibility action encoding failed: {0}")]
1238 Encoding(#[from] serde_json::Error),
1239}
1240
1241pub 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#[derive(Clone, Debug, Eq, PartialEq)]
1260pub struct GitPublicationRecord {
1261 pub tree: GitTreeRef,
1263 pub workspace_tree: GitTreeRef,
1265 pub source_head: Option<GitCommitId>,
1267 pub tracked_paths: BTreeSet<String>,
1269 pub capture_proof: Option<GitCaptureProof>,
1271 pub message: String,
1273 pub author: String,
1275 pub authored_at_seconds: i64,
1277}
1278
1279impl<S> GitCompatRepository<S> {
1280 #[must_use]
1282 pub const fn new(workspace_id: WorkspaceId, store: S) -> Self {
1283 Self {
1284 workspace_id,
1285 store,
1286 }
1287 }
1288
1289 #[must_use]
1291 pub const fn store(&self) -> &S {
1292 &self.store
1293 }
1294}
1295
1296impl<S: GitCompatStore> GitCompatRepository<S> {
1297 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 || !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, ¤t, 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#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
3732pub struct GitIgnorePolicy {
3733 rules: Vec<GitIgnoreRule>,
3734}
3735
3736pub struct GitCapturedGeneration<A, O> {
3738 pub generation: Generation<A, O>,
3740 initial_generation: Option<crate::GenerationId>,
3742 pub tracked_paths: BTreeSet<String>,
3744 fork_parent: Option<Generation<A, O>>,
3745}
3746
3747impl<A: AsyncAuthorityStore, O: AsyncObjectStore> GitCapturedGeneration<A, O> {
3748 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 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
3817pub struct GitTreeEntry {
3818 pub path: String,
3820 pub kind: String,
3822}
3823
3824#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
3826pub struct GitGrepMatch {
3827 pub path: String,
3829 pub line: u64,
3831 pub text: String,
3833}
3834
3835#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
3837pub struct GitGrepResult {
3838 pub matches: Vec<GitGrepMatch>,
3840 pub truncated: bool,
3842}
3843
3844#[derive(Debug, Error)]
3846pub enum GitPatchError {
3847 #[error(transparent)]
3849 Workspace(#[from] WorkspaceError),
3850 #[error("Git patch is not UTF-8 unified text")]
3852 NonText,
3853 #[error("invalid Git unified patch: {0}")]
3855 Invalid(String),
3856 #[error("Git patch hunk does not apply to '{path}' at source line {line}")]
3858 Conflict {
3859 path: String,
3861 line: usize,
3863 },
3864}
3865
3866#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
3868pub struct GitBlameLine {
3869 pub commit: GitCommitId,
3871 pub line: u64,
3873 pub text: String,
3875}
3876
3877pub 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
3943pub 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
4041pub 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
4100pub 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
4116pub 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#[derive(Debug, Error)]
4357pub enum GitCaptureError {
4358 #[error(transparent)]
4360 Workspace(#[from] WorkspaceError),
4361 #[error("Git compatibility cannot represent a non-UTF-8 path name")]
4363 NonPortableName,
4364 #[error("Git compatibility capture workspace changed concurrently")]
4366 Conflict,
4367}
4368
4369#[derive(Debug, Error)]
4371pub enum GitCaptureAuthenticationError<E: std::error::Error + 'static> {
4372 #[error(transparent)]
4374 Capture(#[from] GitCaptureError),
4375 #[error(transparent)]
4377 Lineage(#[from] crate::WorkspaceLineageError<E>),
4378}
4379
4380#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
4382pub struct GitDiffCounts {
4383 pub file_changes: usize,
4385 pub binding_changes: usize,
4387}
4388
4389pub 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
4580pub 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
4601pub 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 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#[allow(clippy::too_many_lines)] pub 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 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 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 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 #[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 #[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#[derive(Default)]
5073pub struct MemoryGitCompatStore {
5074 states: Mutex<BTreeMap<WorkspaceId, GitCompatState>>,
5075}
5076
5077impl MemoryGitCompatStore {
5078 #[must_use]
5080 pub fn new() -> Self {
5081 Self::default()
5082 }
5083}
5084
5085#[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}