1use std::collections::{BTreeMap, BTreeSet};
4#[cfg(unix)]
5use std::ffi::OsStr;
6use std::ffi::OsString;
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9use std::time::Duration;
10
11use async_trait::async_trait;
12use code_system_graph_model::{CheckoutId, NativePath, NativePathEncoding, RepoId};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16use tokio::io::AsyncReadExt;
17use tokio::process::Command;
18use tokio_util::sync::CancellationToken;
19
20const DEFAULT_OUTPUT_CAP: usize = 16 * 1024 * 1024;
21const DEFAULT_STDERR_CAP: usize = 256 * 1024;
22const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(tag = "kind", rename_all = "snake_case")]
27pub enum ChangeScope {
28 Unstaged,
30 Staged,
32 All,
34 Compare {
36 reference: String,
38 },
39 Commit {
41 sha: String,
43 },
44 Range {
46 base: String,
48 head: String,
50 },
51 PullRequest {
53 provider: String,
55 number: u64,
57 },
58}
59
60#[derive(
62 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
63)]
64#[serde(rename_all = "snake_case")]
65pub enum ChangeSourceLayer {
66 Staged,
68 Worktree,
70 Untracked,
72 Commit,
74}
75
76#[derive(
78 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
79)]
80#[serde(rename_all = "snake_case")]
81pub enum ChangedFileStatus {
82 Added,
84 Modified,
86 Deleted,
88 Renamed,
90 Copied,
92 Untracked,
94}
95
96#[derive(
98 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
99)]
100#[serde(rename_all = "snake_case")]
101pub enum ChangedLineKind {
102 Removed,
104 Added,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
112pub struct ChangedLine {
113 pub kind: ChangedLineKind,
115 pub old_line: Option<u32>,
117 pub new_line: Option<u32>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
123pub struct ChangeHunk {
124 pub old_start: u32,
126 pub old_count: u32,
128 pub new_start: u32,
130 pub new_count: u32,
132 pub lines: Vec<ChangedLine>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
138pub struct ChangedFile {
139 pub status: ChangedFileStatus,
141 pub old_path: Option<NativePath>,
143 pub new_path: Option<NativePath>,
145 pub binary: bool,
147 pub hunks: Vec<ChangeHunk>,
149 pub source: ChangeSourceLayer,
151}
152
153pub type AnalyzerVersions = BTreeMap<String, String>;
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
158pub struct ChangeSet {
159 pub repo_id: RepoId,
161 pub checkout_id: CheckoutId,
163 pub worktree: NativePath,
165 pub git_common_dir: NativePath,
167 pub scope: ChangeScope,
169 pub checkout_head_ref: Option<String>,
171 pub checkout_head_sha: String,
173 pub base_ref: Option<String>,
175 pub head_ref: Option<String>,
177 pub head_sha: String,
179 pub staged_hash: String,
181 pub worktree_hash: String,
183 pub exact_diff_fingerprint: String,
185 pub workspace_manifest_hash: String,
187 pub contract_registry_hash: String,
189 pub analyzer_versions: AnalyzerVersions,
191 pub files: Vec<ChangedFile>,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197pub struct ChangeRequest {
198 pub repo_id: RepoId,
200 pub checkout_id: CheckoutId,
202 pub worktree: PathBuf,
204 pub scope: ChangeScope,
206 pub workspace_manifest_hash: String,
208 pub contract_registry_hash: String,
210 pub analyzer_versions: AnalyzerVersions,
212}
213
214#[async_trait]
216pub trait ChangeProvider: Send + Sync {
217 async fn changes(
224 &self,
225 request: &ChangeRequest,
226 cancellation: &CancellationToken,
227 ) -> Result<ChangeSet, ChangeError>;
228}
229
230#[derive(Debug, Clone)]
232pub struct GitCliChangeProvider {
233 git_binary: OsString,
234 timeout: Duration,
235 stdout_cap: usize,
236 stderr_cap: usize,
237}
238
239impl Default for GitCliChangeProvider {
240 fn default() -> Self {
241 Self {
242 git_binary: OsString::from("git"),
243 timeout: DEFAULT_TIMEOUT,
244 stdout_cap: DEFAULT_OUTPUT_CAP,
245 stderr_cap: DEFAULT_STDERR_CAP,
246 }
247 }
248}
249
250impl GitCliChangeProvider {
251 #[must_use]
253 pub fn new() -> Self {
254 Self::default()
255 }
256
257 #[must_use]
261 pub fn with_limits(
262 git_binary: impl Into<OsString>,
263 timeout: Duration,
264 stdout_cap: usize,
265 stderr_cap: usize,
266 ) -> Self {
267 Self {
268 git_binary: git_binary.into(),
269 timeout,
270 stdout_cap,
271 stderr_cap,
272 }
273 }
274
275 async fn git(
276 &self,
277 worktree: &Path,
278 args: &[OsString],
279 cancellation: &CancellationToken,
280 ) -> Result<Vec<u8>, ChangeError> {
281 if self.stdout_cap == 0 || self.stderr_cap == 0 {
282 return Err(ChangeError::InvalidLimit);
283 }
284 if cancellation.is_cancelled() {
285 return Err(ChangeError::Cancelled);
286 }
287
288 let mut command = Command::new(&self.git_binary);
289 command
290 .arg("-C")
291 .arg(worktree)
292 .args(args)
293 .env("GIT_OPTIONAL_LOCKS", "0")
294 .stdin(Stdio::null())
295 .stdout(Stdio::piped())
296 .stderr(Stdio::piped())
297 .kill_on_drop(true);
298 let mut child = command
299 .spawn()
300 .map_err(|source| ChangeError::Spawn { source })?;
301 let stdout = child.stdout.take().ok_or(ChangeError::MissingPipe)?;
302 let stderr = child.stderr.take().ok_or(ChangeError::MissingPipe)?;
303 let stdout_cap = self.stdout_cap;
304 let stderr_cap = self.stderr_cap;
305 let stdout_task = tokio::spawn(async move { read_bounded(stdout, stdout_cap).await });
306 let stderr_task = tokio::spawn(async move { read_bounded(stderr, stderr_cap).await });
307
308 let status = tokio::select! {
309 () = cancellation.cancelled() => {
310 terminate(&mut child).await;
311 stdout_task.abort();
312 stderr_task.abort();
313 return Err(ChangeError::Cancelled);
314 }
315 () = tokio::time::sleep(self.timeout) => {
316 terminate(&mut child).await;
317 stdout_task.abort();
318 stderr_task.abort();
319 return Err(ChangeError::Timeout { timeout: self.timeout });
320 }
321 result = child.wait() => result.map_err(|source| ChangeError::Wait { source })?,
322 };
323 let stdout = stdout_task
324 .await
325 .map_err(|source| ChangeError::ReaderTask { source })??;
326 let stderr = stderr_task
327 .await
328 .map_err(|source| ChangeError::ReaderTask { source })??;
329 if !status.success() {
330 return Err(ChangeError::GitFailed {
331 status: status.code(),
332 stderr: String::from_utf8_lossy(&stderr).into_owned(),
333 });
334 }
335 Ok(stdout)
336 }
337
338 async fn text_query(
339 &self,
340 worktree: &Path,
341 args: &[&str],
342 cancellation: &CancellationToken,
343 ) -> Result<String, ChangeError> {
344 let args = args.iter().map(OsString::from).collect::<Vec<_>>();
345 let bytes = self.git(worktree, &args, cancellation).await?;
346 let value = std::str::from_utf8(&bytes).map_err(|_| ChangeError::NonUtf8Metadata)?;
347 Ok(value.trim_end_matches(['\r', '\n']).to_owned())
348 }
349
350 async fn collect_layer(
351 &self,
352 worktree: &Path,
353 source: ChangeSourceLayer,
354 revision_args: &[OsString],
355 cancellation: &CancellationToken,
356 ) -> Result<LayerResult, ChangeError> {
357 let mut raw_args = diff_prefix("--raw");
358 raw_args.extend_from_slice(revision_args);
359 let raw = self.git(worktree, &raw_args, cancellation).await?;
360
361 let mut numstat_args = diff_prefix("--numstat");
362 numstat_args.extend_from_slice(revision_args);
363 let numstat = self.git(worktree, &numstat_args, cancellation).await?;
364
365 let mut patch_args = diff_prefix("--patch");
366 patch_args.push(OsString::from("--unified=0"));
367 patch_args.push(OsString::from("--no-prefix"));
368 patch_args.extend_from_slice(revision_args);
369 let patch = self.git(worktree, &patch_args, cancellation).await?;
370
371 let mut files = parse_raw(&raw, source)?;
372 apply_numstat(&numstat, &mut files)?;
373 apply_patch(&patch, &mut files)?;
374 let material = framed_material(&[&raw, &numstat, &patch]);
375 Ok(LayerResult { files, material })
376 }
377
378 async fn collect_untracked(
379 &self,
380 worktree: &Path,
381 cancellation: &CancellationToken,
382 ) -> Result<LayerResult, ChangeError> {
383 let args = [
384 OsString::from("ls-files"),
385 OsString::from("--others"),
386 OsString::from("--exclude-standard"),
387 OsString::from("-z"),
388 ];
389 let output = self.git(worktree, &args, cancellation).await?;
390 let files = output
391 .split(|byte| *byte == 0)
392 .filter(|path| !path.is_empty())
393 .map(|path| ChangedFile {
394 status: ChangedFileStatus::Untracked,
395 old_path: None,
396 new_path: Some(native_path_bytes(path)),
397 binary: false,
398 hunks: Vec::new(),
399 source: ChangeSourceLayer::Untracked,
400 })
401 .collect();
402 Ok(LayerResult {
403 files,
404 material: framed_material(&[&output]),
405 })
406 }
407}
408
409#[async_trait]
410impl ChangeProvider for GitCliChangeProvider {
411 #[expect(
412 clippy::too_many_lines,
413 reason = "scope orchestration remains linear so every Git layer is auditable"
414 )]
415 async fn changes(
416 &self,
417 request: &ChangeRequest,
418 cancellation: &CancellationToken,
419 ) -> Result<ChangeSet, ChangeError> {
420 validate_scope(&request.scope)?;
421 let top = self
422 .text_query(
423 &request.worktree,
424 &["rev-parse", "--show-toplevel"],
425 cancellation,
426 )
427 .await?;
428 let canonical_worktree = canonicalize(Path::new(&top))?;
429 let common = self
430 .text_query(
431 &canonical_worktree,
432 &["rev-parse", "--git-common-dir"],
433 cancellation,
434 )
435 .await?;
436 let common_path = Path::new(&common);
437 let common_path = if common_path.is_absolute() {
438 common_path.to_path_buf()
439 } else {
440 canonical_worktree.join(common_path)
441 };
442 let canonical_common = canonicalize(&common_path)?;
443 let checkout_head_sha = self
444 .text_query(
445 &canonical_worktree,
446 &["rev-parse", "--verify", "HEAD^{commit}"],
447 cancellation,
448 )
449 .await?;
450 let symbolic = self
451 .text_query(
452 &canonical_worktree,
453 &["symbolic-ref", "-q", "HEAD"],
454 cancellation,
455 )
456 .await;
457 let checkout_head_ref = match symbolic {
458 Ok(value) => Some(value),
459 Err(ChangeError::GitFailed { .. }) => None,
460 Err(error) => return Err(error),
461 };
462
463 let mut staged = LayerResult::default();
464 let mut worktree = LayerResult::default();
465 let mut committed = LayerResult::default();
466 let (base_ref, head_ref, head_sha) = match &request.scope {
467 ChangeScope::Unstaged => {
468 worktree = self
469 .collect_layer(
470 &canonical_worktree,
471 ChangeSourceLayer::Worktree,
472 &[],
473 cancellation,
474 )
475 .await?;
476 (None, Some("HEAD".to_owned()), checkout_head_sha.clone())
477 }
478 ChangeScope::Staged => {
479 staged = self
480 .collect_layer(
481 &canonical_worktree,
482 ChangeSourceLayer::Staged,
483 &[OsString::from("--cached")],
484 cancellation,
485 )
486 .await?;
487 (None, Some("HEAD".to_owned()), checkout_head_sha.clone())
488 }
489 ChangeScope::All => {
490 staged = self
491 .collect_layer(
492 &canonical_worktree,
493 ChangeSourceLayer::Staged,
494 &[OsString::from("--cached")],
495 cancellation,
496 )
497 .await?;
498 worktree = self
499 .collect_layer(
500 &canonical_worktree,
501 ChangeSourceLayer::Worktree,
502 &[],
503 cancellation,
504 )
505 .await?;
506 let untracked = self
507 .collect_untracked(&canonical_worktree, cancellation)
508 .await?;
509 worktree.files.extend(untracked.files);
510 worktree.material.extend(untracked.material);
511 (None, Some("HEAD".to_owned()), checkout_head_sha.clone())
512 }
513 ChangeScope::Compare { reference } => {
514 let expression = OsString::from(format!("{reference}...HEAD"));
515 committed = self
516 .collect_layer(
517 &canonical_worktree,
518 ChangeSourceLayer::Commit,
519 &[expression],
520 cancellation,
521 )
522 .await?;
523 (
524 Some(reference.clone()),
525 Some("HEAD".to_owned()),
526 checkout_head_sha.clone(),
527 )
528 }
529 ChangeScope::Commit { sha } => {
530 committed = self
531 .collect_layer(
532 &canonical_worktree,
533 ChangeSourceLayer::Commit,
534 &[OsString::from(format!("{sha}^!"))],
535 cancellation,
536 )
537 .await?;
538 let resolved = self
539 .text_query(
540 &canonical_worktree,
541 &["rev-parse", "--verify", &format!("{sha}^{{commit}}")],
542 cancellation,
543 )
544 .await?;
545 (None, Some(sha.clone()), resolved)
546 }
547 ChangeScope::Range { base, head } => {
548 committed = self
549 .collect_layer(
550 &canonical_worktree,
551 ChangeSourceLayer::Commit,
552 &[OsString::from(format!("{base}..{head}"))],
553 cancellation,
554 )
555 .await?;
556 let resolved = self
557 .text_query(
558 &canonical_worktree,
559 &["rev-parse", "--verify", &format!("{head}^{{commit}}")],
560 cancellation,
561 )
562 .await?;
563 (Some(base.clone()), Some(head.clone()), resolved)
564 }
565 ChangeScope::PullRequest { .. } => return Err(ChangeError::PullRequestUnsupported),
566 };
567
568 let staged_hash = hash_material(b"code-system-graph-staged-v1", &staged.material);
569 let worktree_hash = hash_material(b"code-system-graph-worktree-v1", &worktree.material);
570 let mut files = staged.files;
571 files.extend(worktree.files);
572 files.extend(committed.files);
573 sort_and_deduplicate(&mut files);
574
575 let worktree_native = native_path(&canonical_worktree);
576 let common_native = native_path(&canonical_common);
577 let exact_diff_fingerprint = exact_fingerprint(
578 request,
579 &worktree_native,
580 &common_native,
581 &checkout_head_sha,
582 base_ref.as_deref(),
583 head_ref.as_deref(),
584 &head_sha,
585 &[&staged.material, &worktree.material, &committed.material],
586 )?;
587 Ok(ChangeSet {
588 repo_id: request.repo_id.clone(),
589 checkout_id: request.checkout_id.clone(),
590 worktree: worktree_native,
591 git_common_dir: common_native,
592 scope: request.scope.clone(),
593 checkout_head_ref,
594 checkout_head_sha,
595 base_ref,
596 head_ref,
597 head_sha,
598 staged_hash,
599 worktree_hash,
600 exact_diff_fingerprint,
601 workspace_manifest_hash: request.workspace_manifest_hash.clone(),
602 contract_registry_hash: request.contract_registry_hash.clone(),
603 analyzer_versions: request.analyzer_versions.clone(),
604 files,
605 })
606 }
607}
608
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
611pub struct ChangeValidityInput {
612 pub repo_id: RepoId,
614 pub checkout_id: CheckoutId,
616 pub worktree: NativePath,
618 pub git_common_dir: NativePath,
620 pub checkout_head_sha: String,
622 pub staged_hash: String,
624 pub worktree_hash: String,
626 pub exact_diff_fingerprint: String,
628 pub workspace_manifest_hash: String,
630 pub contract_registry_hash: String,
632 pub analyzer_versions: AnalyzerVersions,
634}
635
636impl From<&ChangeSet> for ChangeValidityInput {
637 fn from(value: &ChangeSet) -> Self {
638 Self {
639 repo_id: value.repo_id.clone(),
640 checkout_id: value.checkout_id.clone(),
641 worktree: value.worktree.clone(),
642 git_common_dir: value.git_common_dir.clone(),
643 checkout_head_sha: value.checkout_head_sha.clone(),
644 staged_hash: value.staged_hash.clone(),
645 worktree_hash: value.worktree_hash.clone(),
646 exact_diff_fingerprint: value.exact_diff_fingerprint.clone(),
647 workspace_manifest_hash: value.workspace_manifest_hash.clone(),
648 contract_registry_hash: value.contract_registry_hash.clone(),
649 analyzer_versions: value.analyzer_versions.clone(),
650 }
651 }
652}
653
654#[derive(
656 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
657)]
658#[serde(rename_all = "snake_case")]
659pub enum StaleReason {
660 RepositoryChanged,
662 CheckoutChanged,
664 WorktreeChanged,
666 GitCommonDirectoryChanged,
668 HeadChanged,
670 StagedChangesChanged,
672 WorktreeChangesChanged,
674 ExactDiffChanged,
676 WorkspaceManifestChanged,
678 ContractRegistryChanged,
680 AnalyzerVersionsChanged,
682}
683
684#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
686#[serde(tag = "status", rename_all = "snake_case")]
687pub enum ChangeValidity {
688 Valid,
690 Stale {
692 reasons: Vec<StaleReason>,
694 remediation: String,
696 },
697}
698
699#[must_use]
701pub fn validate_change_set(
702 change_set: &ChangeSet,
703 current: &ChangeValidityInput,
704) -> ChangeValidity {
705 let mut reasons = Vec::new();
706 compare(
707 &change_set.repo_id,
708 ¤t.repo_id,
709 StaleReason::RepositoryChanged,
710 &mut reasons,
711 );
712 compare(
713 &change_set.checkout_id,
714 ¤t.checkout_id,
715 StaleReason::CheckoutChanged,
716 &mut reasons,
717 );
718 compare(
719 &change_set.worktree,
720 ¤t.worktree,
721 StaleReason::WorktreeChanged,
722 &mut reasons,
723 );
724 compare(
725 &change_set.git_common_dir,
726 ¤t.git_common_dir,
727 StaleReason::GitCommonDirectoryChanged,
728 &mut reasons,
729 );
730 compare(
731 &change_set.checkout_head_sha,
732 ¤t.checkout_head_sha,
733 StaleReason::HeadChanged,
734 &mut reasons,
735 );
736 compare(
737 &change_set.staged_hash,
738 ¤t.staged_hash,
739 StaleReason::StagedChangesChanged,
740 &mut reasons,
741 );
742 compare(
743 &change_set.worktree_hash,
744 ¤t.worktree_hash,
745 StaleReason::WorktreeChangesChanged,
746 &mut reasons,
747 );
748 compare(
749 &change_set.exact_diff_fingerprint,
750 ¤t.exact_diff_fingerprint,
751 StaleReason::ExactDiffChanged,
752 &mut reasons,
753 );
754 compare(
755 &change_set.workspace_manifest_hash,
756 ¤t.workspace_manifest_hash,
757 StaleReason::WorkspaceManifestChanged,
758 &mut reasons,
759 );
760 compare(
761 &change_set.contract_registry_hash,
762 ¤t.contract_registry_hash,
763 StaleReason::ContractRegistryChanged,
764 &mut reasons,
765 );
766 compare(
767 &change_set.analyzer_versions,
768 ¤t.analyzer_versions,
769 StaleReason::AnalyzerVersionsChanged,
770 &mut reasons,
771 );
772 reasons.sort_unstable();
773 reasons.dedup();
774 if reasons.is_empty() {
775 ChangeValidity::Valid
776 } else {
777 ChangeValidity::Stale {
778 reasons,
779 remediation: "Recollect the change set and rerun analysis before committing."
780 .to_owned(),
781 }
782 }
783}
784
785#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
787#[serde(tag = "kind", rename_all = "snake_case")]
788pub enum CommitSelection {
789 Default,
791 AllTracked,
793 Only {
795 pathspecs: Vec<String>,
797 },
798 Include {
800 pathspecs: Vec<String>,
802 },
803}
804
805#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
807pub struct CommitIntent {
808 pub selection: CommitSelection,
810}
811
812#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
814pub struct CommitFileSelection {
815 pub path: NativePath,
817 pub layers: Vec<ChangeSourceLayer>,
819}
820
821#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
823pub struct CommitGate {
824 pub allowed: bool,
826 pub selected: Vec<CommitFileSelection>,
828 pub reasons: Vec<String>,
830}
831
832pub fn evaluate_commit_gate(
839 change_set: &ChangeSet,
840 intent: &CommitIntent,
841) -> Result<CommitGate, ChangeError> {
842 let pathspecs = match &intent.selection {
843 CommitSelection::Only { pathspecs } | CommitSelection::Include { pathspecs } => {
844 for pathspec in pathspecs {
845 validate_pathspec(pathspec)?;
846 }
847 Some(pathspecs.as_slice())
848 }
849 CommitSelection::Default | CommitSelection::AllTracked => None,
850 };
851 let mut selected = BTreeMap::<NativePath, BTreeSet<ChangeSourceLayer>>::new();
852 for file in &change_set.files {
853 let Some(path) = effective_path(file) else {
854 continue;
855 };
856 let matched = pathspecs.is_none_or(|specs| {
857 specs
858 .iter()
859 .any(|spec| literal_pathspec_matches(spec, &path.bytes))
860 });
861 let include = match intent.selection {
862 CommitSelection::Default => file.source == ChangeSourceLayer::Staged,
863 CommitSelection::AllTracked => {
864 matches!(
865 file.source,
866 ChangeSourceLayer::Staged | ChangeSourceLayer::Worktree
867 )
868 }
869 CommitSelection::Only { .. } => {
870 matched
871 && matches!(
872 file.source,
873 ChangeSourceLayer::Staged
874 | ChangeSourceLayer::Worktree
875 | ChangeSourceLayer::Untracked
876 )
877 }
878 CommitSelection::Include { .. } => {
879 file.source == ChangeSourceLayer::Staged
880 || (matched
881 && matches!(
882 file.source,
883 ChangeSourceLayer::Worktree | ChangeSourceLayer::Untracked
884 ))
885 }
886 };
887 if include {
888 selected
889 .entry(path.clone())
890 .or_default()
891 .insert(file.source);
892 }
893 }
894
895 let mut reasons = Vec::new();
896 if selected.is_empty() {
897 reasons.push("The modeled command selects no analyzed changes.".to_owned());
898 }
899 if matches!(intent.selection, CommitSelection::Only { .. })
900 && selected.values().any(|layers| layers.len() > 1)
901 {
902 reasons.push(
903 "`--only` overlaps staged and worktree layers; the exact committed blob is ambiguous."
904 .to_owned(),
905 );
906 }
907 let selected = selected
908 .into_iter()
909 .map(|(path, layers)| CommitFileSelection {
910 path,
911 layers: layers.into_iter().collect(),
912 })
913 .collect();
914 Ok(CommitGate {
915 allowed: reasons.is_empty(),
916 selected,
917 reasons,
918 })
919}
920
921#[derive(Debug, Error)]
923pub enum ChangeError {
924 #[error("the local Git provider does not support pull-request scopes")]
926 PullRequestUnsupported,
927 #[error("invalid Git ref `{0}`")]
929 InvalidRef(String),
930 #[error("invalid full commit SHA `{0}`")]
932 InvalidSha(String),
933 #[error("invalid literal pathspec `{0}`")]
935 InvalidPathspec(String),
936 #[error("Git output limits must be greater than zero")]
938 InvalidLimit,
939 #[error("failed to spawn Git")]
941 Spawn {
942 #[source]
944 source: std::io::Error,
945 },
946 #[error("Git child process did not expose a required pipe")]
948 MissingPipe,
949 #[error("failed while waiting for Git")]
951 Wait {
952 #[source]
954 source: std::io::Error,
955 },
956 #[error("failed while reading bounded Git output")]
958 Read {
959 #[source]
961 source: std::io::Error,
962 },
963 #[error("Git output reader task failed")]
965 ReaderTask {
966 #[source]
968 source: tokio::task::JoinError,
969 },
970 #[error("Git {stream} exceeded its {limit}-byte output limit")]
972 OutputLimit {
973 stream: &'static str,
975 limit: usize,
977 },
978 #[error("Git failed with status {status:?}: {stderr}")]
980 GitFailed {
981 status: Option<i32>,
983 stderr: String,
985 },
986 #[error("Git operation timed out after {timeout:?}")]
988 Timeout {
989 timeout: Duration,
991 },
992 #[error("Git operation was cancelled")]
994 Cancelled,
995 #[error("Git returned non-UTF-8 identity metadata")]
997 NonUtf8Metadata,
998 #[error("failed to canonicalize `{path}`")]
1000 Canonicalize {
1001 path: PathBuf,
1003 #[source]
1005 source: std::io::Error,
1006 },
1007 #[error("malformed bounded Git output: {0}")]
1009 MalformedGitOutput(String),
1010 #[error("failed to encode change identity")]
1012 FingerprintEncoding {
1013 #[source]
1015 source: serde_json::Error,
1016 },
1017}
1018
1019#[derive(Debug, Default)]
1020struct LayerResult {
1021 files: Vec<ChangedFile>,
1022 material: Vec<u8>,
1023}
1024
1025fn diff_prefix(mode: &str) -> Vec<OsString> {
1026 vec![
1027 OsString::from("diff"),
1028 OsString::from(mode),
1029 OsString::from("-z"),
1030 OsString::from("--find-renames"),
1031 OsString::from("--find-copies"),
1032 OsString::from("--no-ext-diff"),
1033 OsString::from("--no-color"),
1034 ]
1035}
1036
1037async fn read_bounded<R>(reader: R, limit: usize) -> Result<Vec<u8>, ChangeError>
1038where
1039 R: tokio::io::AsyncRead + Unpin,
1040{
1041 let maximum = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1);
1042 let mut output = Vec::new();
1043 reader
1044 .take(maximum)
1045 .read_to_end(&mut output)
1046 .await
1047 .map_err(|source| ChangeError::Read { source })?;
1048 if output.len() > limit {
1049 return Err(ChangeError::OutputLimit {
1050 stream: "stream",
1051 limit,
1052 });
1053 }
1054 Ok(output)
1055}
1056
1057async fn terminate(child: &mut tokio::process::Child) {
1058 let _ignored = child.start_kill();
1059 let _ignored = child.wait().await;
1060}
1061
1062fn canonicalize(path: &Path) -> Result<PathBuf, ChangeError> {
1063 std::fs::canonicalize(path).map_err(|source| ChangeError::Canonicalize {
1064 path: path.to_path_buf(),
1065 source,
1066 })
1067}
1068
1069fn validate_scope(scope: &ChangeScope) -> Result<(), ChangeError> {
1070 match scope {
1071 ChangeScope::Compare { reference } => validate_ref(reference),
1072 ChangeScope::Commit { sha } => validate_sha(sha),
1073 ChangeScope::Range { base, head } => {
1074 validate_ref(base)?;
1075 validate_ref(head)
1076 }
1077 ChangeScope::PullRequest { provider, .. } => {
1078 if provider.is_empty() || provider.chars().any(char::is_control) {
1079 return Err(ChangeError::InvalidRef(provider.clone()));
1080 }
1081 Err(ChangeError::PullRequestUnsupported)
1082 }
1083 ChangeScope::Unstaged | ChangeScope::Staged | ChangeScope::All => Ok(()),
1084 }
1085}
1086
1087fn validate_ref(reference: &str) -> Result<(), ChangeError> {
1088 let valid_chars = reference
1089 .bytes()
1090 .all(|byte| byte.is_ascii_alphanumeric() || b"._/-".contains(&byte));
1091 let invalid = reference.is_empty()
1092 || reference.starts_with('-')
1093 || reference.starts_with('/')
1094 || reference.ends_with('/')
1095 || reference.contains("..")
1096 || reference.contains("//")
1097 || reference.contains("@{")
1098 || reference.ends_with('.')
1099 || Path::new(reference)
1100 .extension()
1101 .is_some_and(|extension| extension.eq_ignore_ascii_case("lock"))
1102 || !valid_chars;
1103 if invalid {
1104 Err(ChangeError::InvalidRef(reference.to_owned()))
1105 } else {
1106 Ok(())
1107 }
1108}
1109
1110fn validate_sha(sha: &str) -> Result<(), ChangeError> {
1111 if matches!(sha.len(), 40 | 64) && sha.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1112 Ok(())
1113 } else {
1114 Err(ChangeError::InvalidSha(sha.to_owned()))
1115 }
1116}
1117
1118fn validate_pathspec(pathspec: &str) -> Result<(), ChangeError> {
1119 let path = Path::new(pathspec);
1120 let invalid = pathspec.is_empty()
1121 || pathspec.starts_with('-')
1122 || pathspec.starts_with(":(")
1123 || pathspec.contains("..")
1124 || pathspec.contains(['*', '?', '[', ']'])
1125 || pathspec.chars().any(char::is_control)
1126 || path.is_absolute()
1127 || path
1128 .components()
1129 .any(|component| matches!(component, std::path::Component::ParentDir));
1130 if invalid {
1131 Err(ChangeError::InvalidPathspec(pathspec.to_owned()))
1132 } else {
1133 Ok(())
1134 }
1135}
1136
1137fn parse_raw(raw: &[u8], source: ChangeSourceLayer) -> Result<Vec<ChangedFile>, ChangeError> {
1138 let mut files = Vec::new();
1139 let mut cursor = 0;
1140 while cursor < raw.len() {
1141 let header = take_nul(raw, &mut cursor)?;
1142 if header.is_empty() {
1143 continue;
1144 }
1145 if header.first() != Some(&b':') {
1146 return Err(ChangeError::MalformedGitOutput(
1147 "raw record does not start with ':'".to_owned(),
1148 ));
1149 }
1150 let status_byte = header
1151 .split(u8::is_ascii_whitespace)
1152 .next_back()
1153 .and_then(|field| field.first())
1154 .copied()
1155 .ok_or_else(|| ChangeError::MalformedGitOutput("missing raw status".to_owned()))?;
1156 let first_path = take_nul(raw, &mut cursor)?;
1157 let (status, old_path, new_path) = match status_byte {
1158 b'A' => (ChangedFileStatus::Added, None, Some(first_path)),
1159 b'M' | b'T' | b'U' => (
1160 ChangedFileStatus::Modified,
1161 Some(first_path),
1162 Some(first_path),
1163 ),
1164 b'D' => (ChangedFileStatus::Deleted, Some(first_path), None),
1165 b'R' => (
1166 ChangedFileStatus::Renamed,
1167 Some(first_path),
1168 Some(take_nul(raw, &mut cursor)?),
1169 ),
1170 b'C' => (
1171 ChangedFileStatus::Copied,
1172 Some(first_path),
1173 Some(take_nul(raw, &mut cursor)?),
1174 ),
1175 other => {
1176 return Err(ChangeError::MalformedGitOutput(format!(
1177 "unsupported raw status `{}`",
1178 char::from(other)
1179 )));
1180 }
1181 };
1182 files.push(ChangedFile {
1183 status,
1184 old_path: old_path.map(native_path_bytes),
1185 new_path: new_path.map(native_path_bytes),
1186 binary: false,
1187 hunks: Vec::new(),
1188 source,
1189 });
1190 }
1191 Ok(files)
1192}
1193
1194fn take_nul<'a>(bytes: &'a [u8], cursor: &mut usize) -> Result<&'a [u8], ChangeError> {
1195 let remaining = bytes
1196 .get(*cursor..)
1197 .ok_or_else(|| ChangeError::MalformedGitOutput("cursor exceeds output".to_owned()))?;
1198 let length = remaining
1199 .iter()
1200 .position(|byte| *byte == 0)
1201 .ok_or_else(|| ChangeError::MalformedGitOutput("missing NUL delimiter".to_owned()))?;
1202 let value = &remaining[..length];
1203 *cursor = cursor.saturating_add(length).saturating_add(1);
1204 Ok(value)
1205}
1206
1207fn apply_numstat(numstat: &[u8], files: &mut [ChangedFile]) -> Result<(), ChangeError> {
1208 let mut cursor = 0;
1209 let mut index = 0;
1210 while cursor < numstat.len() {
1211 let record = take_nul(numstat, &mut cursor)?;
1212 if record.is_empty() {
1213 continue;
1214 }
1215 let mut fields = record.splitn(3, |byte| *byte == b'\t');
1216 let added = fields.next();
1217 let removed = fields.next();
1218 let path = fields.next();
1219 let (Some(added), Some(removed), Some(path)) = (added, removed, path) else {
1220 return Err(ChangeError::MalformedGitOutput(
1221 "invalid numstat record".to_owned(),
1222 ));
1223 };
1224 let file = files.get_mut(index).ok_or_else(|| {
1225 ChangeError::MalformedGitOutput("numstat has too many records".to_owned())
1226 })?;
1227 file.binary = added == b"-" || removed == b"-";
1228 if path.is_empty() {
1229 let _old = take_nul(numstat, &mut cursor)?;
1230 let _new = take_nul(numstat, &mut cursor)?;
1231 }
1232 index = index.saturating_add(1);
1233 }
1234 if index != files.len() {
1235 return Err(ChangeError::MalformedGitOutput(
1236 "raw and numstat record counts differ".to_owned(),
1237 ));
1238 }
1239 Ok(())
1240}
1241
1242fn apply_patch(patch: &[u8], files: &mut [ChangedFile]) -> Result<(), ChangeError> {
1243 let mut file_index: Option<usize> = None;
1244 let mut current_hunk: Option<ChangeHunk> = None;
1245 let mut old_line = 0_u32;
1246 let mut new_line = 0_u32;
1247 for line in patch.split_inclusive(|byte| *byte == b'\n') {
1248 if line.starts_with(b"diff --git ") {
1249 finish_hunk(files, file_index, current_hunk.take())?;
1250 file_index = Some(file_index.map_or(0, |index| index.saturating_add(1)));
1251 continue;
1252 }
1253 if line.starts_with(b"@@ ") {
1254 finish_hunk(files, file_index, current_hunk.take())?;
1255 let hunk = parse_hunk_header(line)?;
1256 old_line = hunk.old_start;
1257 new_line = hunk.new_start;
1258 current_hunk = Some(hunk);
1259 continue;
1260 }
1261 let Some(hunk) = current_hunk.as_mut() else {
1262 continue;
1263 };
1264 if line.starts_with(b"+") && !line.starts_with(b"+++") {
1265 hunk.lines.push(ChangedLine {
1266 kind: ChangedLineKind::Added,
1267 old_line: None,
1268 new_line: Some(new_line),
1269 });
1270 new_line = new_line.saturating_add(1);
1271 } else if line.starts_with(b"-") && !line.starts_with(b"---") {
1272 hunk.lines.push(ChangedLine {
1273 kind: ChangedLineKind::Removed,
1274 old_line: Some(old_line),
1275 new_line: None,
1276 });
1277 old_line = old_line.saturating_add(1);
1278 } else if line.starts_with(b" ") {
1279 old_line = old_line.saturating_add(1);
1280 new_line = new_line.saturating_add(1);
1281 }
1282 }
1283 finish_hunk(files, file_index, current_hunk)?;
1284 if file_index.is_some_and(|index| index >= files.len()) {
1285 return Err(ChangeError::MalformedGitOutput(
1286 "patch has more file sections than raw output".to_owned(),
1287 ));
1288 }
1289 Ok(())
1290}
1291
1292fn finish_hunk(
1293 files: &mut [ChangedFile],
1294 file_index: Option<usize>,
1295 hunk: Option<ChangeHunk>,
1296) -> Result<(), ChangeError> {
1297 if let Some(hunk) = hunk {
1298 let index = file_index.ok_or_else(|| {
1299 ChangeError::MalformedGitOutput("hunk precedes file header".to_owned())
1300 })?;
1301 files
1302 .get_mut(index)
1303 .ok_or_else(|| {
1304 ChangeError::MalformedGitOutput("patch file index exceeds raw output".to_owned())
1305 })?
1306 .hunks
1307 .push(hunk);
1308 }
1309 Ok(())
1310}
1311
1312fn parse_hunk_header(line: &[u8]) -> Result<ChangeHunk, ChangeError> {
1313 let text = std::str::from_utf8(line)
1314 .map_err(|_| ChangeError::MalformedGitOutput("non-ASCII hunk header".to_owned()))?;
1315 let mut fields = text.split_ascii_whitespace();
1316 if fields.next() != Some("@@") {
1317 return Err(ChangeError::MalformedGitOutput(
1318 "invalid hunk marker".to_owned(),
1319 ));
1320 }
1321 let old = fields
1322 .next()
1323 .ok_or_else(|| ChangeError::MalformedGitOutput("missing old hunk range".to_owned()))?;
1324 let new = fields
1325 .next()
1326 .ok_or_else(|| ChangeError::MalformedGitOutput("missing new hunk range".to_owned()))?;
1327 let (old_start, old_count) = parse_range(old, '-')?;
1328 let (new_start, new_count) = parse_range(new, '+')?;
1329 Ok(ChangeHunk {
1330 old_start,
1331 old_count,
1332 new_start,
1333 new_count,
1334 lines: Vec::new(),
1335 })
1336}
1337
1338fn parse_range(value: &str, prefix: char) -> Result<(u32, u32), ChangeError> {
1339 let value = value
1340 .strip_prefix(prefix)
1341 .ok_or_else(|| ChangeError::MalformedGitOutput("invalid hunk range prefix".to_owned()))?;
1342 let (start, count) = value.split_once(',').unwrap_or((value, "1"));
1343 let start = start
1344 .parse()
1345 .map_err(|_| ChangeError::MalformedGitOutput("invalid hunk start".to_owned()))?;
1346 let count = count
1347 .parse()
1348 .map_err(|_| ChangeError::MalformedGitOutput("invalid hunk count".to_owned()))?;
1349 Ok((start, count))
1350}
1351
1352fn sort_and_deduplicate(files: &mut Vec<ChangedFile>) {
1353 files.sort_by(|left, right| {
1354 left.source
1355 .cmp(&right.source)
1356 .then_with(|| effective_path(left).cmp(&effective_path(right)))
1357 .then_with(|| left.status.cmp(&right.status))
1358 .then_with(|| left.old_path.cmp(&right.old_path))
1359 });
1360 files.dedup();
1361}
1362
1363fn effective_path(file: &ChangedFile) -> Option<&NativePath> {
1364 file.new_path.as_ref().or(file.old_path.as_ref())
1365}
1366
1367fn literal_pathspec_matches(pathspec: &str, path: &[u8]) -> bool {
1368 let spec = pathspec.trim_end_matches('/').as_bytes();
1369 path == spec
1370 || (path.starts_with(spec)
1371 && path
1372 .get(spec.len())
1373 .is_some_and(|separator| *separator == b'/'))
1374}
1375
1376fn framed_material(parts: &[&[u8]]) -> Vec<u8> {
1377 let capacity = parts.iter().map(|part| part.len().saturating_add(8)).sum();
1378 let mut output = Vec::with_capacity(capacity);
1379 for part in parts {
1380 output.extend_from_slice(&u64::try_from(part.len()).unwrap_or(u64::MAX).to_le_bytes());
1381 output.extend_from_slice(part);
1382 }
1383 output
1384}
1385
1386fn hash_material(namespace: &[u8], material: &[u8]) -> String {
1387 let mut hasher = blake3::Hasher::new();
1388 hasher.update(namespace);
1389 hasher.update(&[0]);
1390 hasher.update(material);
1391 hasher.finalize().to_hex().to_string()
1392}
1393
1394#[expect(
1395 clippy::too_many_arguments,
1396 reason = "all identity inputs are fingerprint material"
1397)]
1398fn exact_fingerprint(
1399 request: &ChangeRequest,
1400 worktree: &NativePath,
1401 common: &NativePath,
1402 checkout_head_sha: &str,
1403 base_ref: Option<&str>,
1404 head_ref: Option<&str>,
1405 head_sha: &str,
1406 materials: &[&[u8]],
1407) -> Result<String, ChangeError> {
1408 let identity = serde_json::to_vec(&(
1409 &request.repo_id,
1410 &request.checkout_id,
1411 worktree,
1412 common,
1413 &request.scope,
1414 checkout_head_sha,
1415 base_ref,
1416 head_ref,
1417 head_sha,
1418 &request.workspace_manifest_hash,
1419 &request.contract_registry_hash,
1420 &request.analyzer_versions,
1421 ))
1422 .map_err(|source| ChangeError::FingerprintEncoding { source })?;
1423 let mut hasher = blake3::Hasher::new();
1424 hasher.update(b"code-system-graph-exact-diff-v1");
1425 hasher.update(
1426 &u64::try_from(identity.len())
1427 .unwrap_or(u64::MAX)
1428 .to_le_bytes(),
1429 );
1430 hasher.update(&identity);
1431 for material in materials {
1432 hasher.update(
1433 &u64::try_from(material.len())
1434 .unwrap_or(u64::MAX)
1435 .to_le_bytes(),
1436 );
1437 hasher.update(material);
1438 }
1439 Ok(hasher.finalize().to_hex().to_string())
1440}
1441
1442fn compare<T: PartialEq>(stored: &T, current: &T, reason: StaleReason, out: &mut Vec<StaleReason>) {
1443 if stored != current {
1444 out.push(reason);
1445 }
1446}
1447
1448#[cfg(unix)]
1449fn native_path(path: &Path) -> NativePath {
1450 use std::os::unix::ffi::OsStrExt;
1451
1452 NativePath {
1453 encoding: NativePathEncoding::UnixBytes,
1454 bytes: path.as_os_str().as_bytes().to_vec(),
1455 display: path.to_string_lossy().into_owned(),
1456 }
1457}
1458
1459#[cfg(windows)]
1460fn native_path(path: &Path) -> NativePath {
1461 use std::os::windows::ffi::OsStrExt;
1462
1463 NativePath {
1464 encoding: NativePathEncoding::WindowsWide,
1465 bytes: path
1466 .as_os_str()
1467 .encode_wide()
1468 .flat_map(u16::to_le_bytes)
1469 .collect(),
1470 display: path.to_string_lossy().into_owned(),
1471 }
1472}
1473
1474#[cfg(not(any(unix, windows)))]
1475fn native_path(path: &Path) -> NativePath {
1476 NativePath {
1477 encoding: NativePathEncoding::Utf8,
1478 bytes: path.to_string_lossy().as_bytes().to_vec(),
1479 display: path.to_string_lossy().into_owned(),
1480 }
1481}
1482
1483fn native_path_bytes(bytes: &[u8]) -> NativePath {
1484 #[cfg(unix)]
1485 {
1486 use std::os::unix::ffi::OsStrExt;
1487
1488 let value = OsStr::from_bytes(bytes);
1489 NativePath {
1490 encoding: NativePathEncoding::UnixBytes,
1491 bytes: bytes.to_vec(),
1492 display: value.to_string_lossy().into_owned(),
1493 }
1494 }
1495 #[cfg(not(unix))]
1496 {
1497 NativePath {
1498 encoding: NativePathEncoding::Utf8,
1499 bytes: bytes.to_vec(),
1500 display: String::from_utf8_lossy(bytes).into_owned(),
1501 }
1502 }
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507 use std::fs;
1508 use std::process::Command as StdCommand;
1509
1510 use tempfile::TempDir;
1511
1512 use super::*;
1513
1514 type TestResult = Result<(), Box<dyn std::error::Error>>;
1515
1516 fn path(value: &str) -> NativePath {
1517 native_path_bytes(value.as_bytes())
1518 }
1519
1520 fn file(name: &str, source: ChangeSourceLayer) -> ChangedFile {
1521 ChangedFile {
1522 status: ChangedFileStatus::Modified,
1523 old_path: Some(path(name)),
1524 new_path: Some(path(name)),
1525 binary: false,
1526 hunks: Vec::new(),
1527 source,
1528 }
1529 }
1530
1531 fn sample_set(files: Vec<ChangedFile>) -> ChangeSet {
1532 ChangeSet {
1533 repo_id: RepoId::new("repo:test"),
1534 checkout_id: CheckoutId::new("checkout:test"),
1535 worktree: path("/tmp/repo"),
1536 git_common_dir: path("/tmp/repo/.git"),
1537 scope: ChangeScope::All,
1538 checkout_head_ref: Some("refs/heads/main".to_owned()),
1539 checkout_head_sha: "a".repeat(40),
1540 base_ref: None,
1541 head_ref: Some("HEAD".to_owned()),
1542 head_sha: "a".repeat(40),
1543 staged_hash: "staged".to_owned(),
1544 worktree_hash: "worktree".to_owned(),
1545 exact_diff_fingerprint: "exact".to_owned(),
1546 workspace_manifest_hash: "manifest".to_owned(),
1547 contract_registry_hash: "contracts".to_owned(),
1548 analyzer_versions: BTreeMap::from([("core".to_owned(), "1".to_owned())]),
1549 files,
1550 }
1551 }
1552
1553 fn git(repo: &Path, args: &[&str]) -> TestResult {
1554 let status = StdCommand::new("git")
1555 .arg("-C")
1556 .arg(repo)
1557 .args(args)
1558 .stdin(Stdio::null())
1559 .status()?;
1560 if status.success() {
1561 Ok(())
1562 } else {
1563 Err(format!("git failed with {status}").into())
1564 }
1565 }
1566
1567 fn repo() -> Result<TempDir, Box<dyn std::error::Error>> {
1568 let temp = tempfile::tempdir()?;
1569 git(temp.path(), &["init", "-q"])?;
1570 git(
1571 temp.path(),
1572 &["config", "user.name", "Code System Graph Test"],
1573 )?;
1574 git(
1575 temp.path(),
1576 &["config", "user.email", "test@example.invalid"],
1577 )?;
1578 fs::write(temp.path().join("tracked.txt"), "one\n")?;
1579 git(temp.path(), &["add", "tracked.txt"])?;
1580 git(temp.path(), &["commit", "-qm", "initial"])?;
1581 Ok(temp)
1582 }
1583
1584 fn request(repo: &Path, scope: ChangeScope) -> ChangeRequest {
1585 ChangeRequest {
1586 repo_id: RepoId::new("repo:test"),
1587 checkout_id: CheckoutId::new("checkout:test"),
1588 worktree: repo.to_path_buf(),
1589 scope,
1590 workspace_manifest_hash: "manifest".to_owned(),
1591 contract_registry_hash: "contracts".to_owned(),
1592 analyzer_versions: BTreeMap::from([("core".to_owned(), "1".to_owned())]),
1593 }
1594 }
1595
1596 async fn collect(repo: &Path, scope: ChangeScope) -> Result<ChangeSet, ChangeError> {
1597 GitCliChangeProvider::new()
1598 .changes(&request(repo, scope), &CancellationToken::new())
1599 .await
1600 }
1601
1602 #[test]
1603 fn ref_validation_accepts_simple_names() {
1604 assert!(validate_ref("refs/heads/feature-x").is_ok());
1605 }
1606
1607 #[test]
1608 fn ref_validation_rejects_option_injection() {
1609 assert!(validate_ref("--output=/tmp/pwn").is_err());
1610 }
1611
1612 #[test]
1613 fn ref_validation_rejects_range_injection() {
1614 assert!(validate_ref("main..evil").is_err());
1615 }
1616
1617 #[test]
1618 fn sha_validation_accepts_full_sha1() {
1619 assert!(validate_sha(&"a".repeat(40)).is_ok());
1620 }
1621
1622 #[test]
1623 fn sha_validation_rejects_short_object_id() {
1624 assert!(validate_sha("deadbeef").is_err());
1625 }
1626
1627 #[test]
1628 fn pathspec_validation_rejects_options() {
1629 assert!(validate_pathspec("-a").is_err());
1630 }
1631
1632 #[test]
1633 fn pathspec_validation_rejects_magic() {
1634 assert!(validate_pathspec(":(top)src").is_err());
1635 }
1636
1637 #[test]
1638 fn pathspec_matching_includes_directory_children() {
1639 assert!(literal_pathspec_matches("src", b"src/lib.rs"));
1640 }
1641
1642 #[test]
1643 fn raw_parser_handles_rename() -> TestResult {
1644 let raw = b":100644 100644 aaaaaaa bbbbbbb R100\0old.rs\0new.rs\0";
1645 let parsed = parse_raw(raw, ChangeSourceLayer::Staged)?;
1646 assert_eq!(parsed[0].status, ChangedFileStatus::Renamed);
1647 Ok(())
1648 }
1649
1650 #[test]
1651 fn raw_parser_preserves_non_utf8_path() -> TestResult {
1652 let raw = b":000000 100644 0000000 bbbbbbb A\0bad-\xff\0";
1653 let parsed = parse_raw(raw, ChangeSourceLayer::Staged)?;
1654 assert_eq!(
1655 parsed[0]
1656 .new_path
1657 .as_ref()
1658 .map(|value| value.bytes.as_slice()),
1659 Some(&b"bad-\xff"[..])
1660 );
1661 Ok(())
1662 }
1663
1664 #[test]
1665 fn numstat_marks_binary() -> TestResult {
1666 let mut files = vec![file("image.bin", ChangeSourceLayer::Staged)];
1667 apply_numstat(b"-\t-\timage.bin\0", &mut files)?;
1668 assert!(files[0].binary);
1669 Ok(())
1670 }
1671
1672 #[test]
1673 fn patch_parser_records_positions_without_text() -> TestResult {
1674 let mut files = vec![file("a.txt", ChangeSourceLayer::Worktree)];
1675 apply_patch(
1676 b"diff --git a.txt a.txt\n@@ -1 +1,2 @@\n-old secret\n+new secret\n+second\n",
1677 &mut files,
1678 )?;
1679 assert_eq!(files[0].hunks[0].lines.len(), 3);
1680 Ok(())
1681 }
1682
1683 #[test]
1684 fn validity_is_valid_for_identical_input() {
1685 let set = sample_set(Vec::new());
1686 assert_eq!(
1687 validate_change_set(&set, &ChangeValidityInput::from(&set)),
1688 ChangeValidity::Valid
1689 );
1690 }
1691
1692 #[test]
1693 fn validity_reports_manifest_change() {
1694 let set = sample_set(Vec::new());
1695 let mut input = ChangeValidityInput::from(&set);
1696 input.workspace_manifest_hash = "new".to_owned();
1697 assert!(matches!(
1698 validate_change_set(&set, &input),
1699 ChangeValidity::Stale { reasons, .. }
1700 if reasons == vec![StaleReason::WorkspaceManifestChanged]
1701 ));
1702 }
1703
1704 #[test]
1705 fn default_commit_gate_selects_only_staged() -> TestResult {
1706 let set = sample_set(vec![
1707 file("a", ChangeSourceLayer::Staged),
1708 file("b", ChangeSourceLayer::Worktree),
1709 ]);
1710 let gate = evaluate_commit_gate(
1711 &set,
1712 &CommitIntent {
1713 selection: CommitSelection::Default,
1714 },
1715 )?;
1716 assert_eq!(gate.selected.len(), 1);
1717 Ok(())
1718 }
1719
1720 #[test]
1721 fn all_tracked_commit_gate_excludes_untracked() -> TestResult {
1722 let set = sample_set(vec![
1723 file("a", ChangeSourceLayer::Worktree),
1724 file("b", ChangeSourceLayer::Untracked),
1725 ]);
1726 let gate = evaluate_commit_gate(
1727 &set,
1728 &CommitIntent {
1729 selection: CommitSelection::AllTracked,
1730 },
1731 )?;
1732 assert_eq!(gate.selected.len(), 1);
1733 Ok(())
1734 }
1735
1736 #[test]
1737 fn only_commit_gate_fails_on_layer_ambiguity() -> TestResult {
1738 let set = sample_set(vec![
1739 file("a", ChangeSourceLayer::Staged),
1740 file("a", ChangeSourceLayer::Worktree),
1741 ]);
1742 let gate = evaluate_commit_gate(
1743 &set,
1744 &CommitIntent {
1745 selection: CommitSelection::Only {
1746 pathspecs: vec!["a".to_owned()],
1747 },
1748 },
1749 )?;
1750 assert!(!gate.allowed);
1751 Ok(())
1752 }
1753
1754 #[test]
1755 fn include_commit_gate_keeps_existing_index() -> TestResult {
1756 let set = sample_set(vec![
1757 file("staged", ChangeSourceLayer::Staged),
1758 file("src/new", ChangeSourceLayer::Worktree),
1759 ]);
1760 let gate = evaluate_commit_gate(
1761 &set,
1762 &CommitIntent {
1763 selection: CommitSelection::Include {
1764 pathspecs: vec!["src".to_owned()],
1765 },
1766 },
1767 )?;
1768 assert_eq!(gate.selected.len(), 2);
1769 Ok(())
1770 }
1771
1772 #[tokio::test]
1773 async fn unstaged_scope_reports_tracked_modification() -> TestResult {
1774 let repo = repo()?;
1775 fs::write(repo.path().join("tracked.txt"), "two\n")?;
1776 let set = collect(repo.path(), ChangeScope::Unstaged).await?;
1777 assert_eq!(set.files[0].source, ChangeSourceLayer::Worktree);
1778 Ok(())
1779 }
1780
1781 #[tokio::test]
1782 async fn staged_scope_reports_index_addition() -> TestResult {
1783 let repo = repo()?;
1784 fs::write(repo.path().join("added.txt"), "added\n")?;
1785 git(repo.path(), &["add", "added.txt"])?;
1786 let set = collect(repo.path(), ChangeScope::Staged).await?;
1787 assert_eq!(set.files[0].status, ChangedFileStatus::Added);
1788 Ok(())
1789 }
1790
1791 #[tokio::test]
1792 async fn all_scope_keeps_layers_distinct() -> TestResult {
1793 let repo = repo()?;
1794 fs::write(repo.path().join("tracked.txt"), "staged\n")?;
1795 git(repo.path(), &["add", "tracked.txt"])?;
1796 fs::write(repo.path().join("tracked.txt"), "worktree\n")?;
1797 fs::write(repo.path().join("untracked.txt"), "untracked body\n")?;
1798 let set = collect(repo.path(), ChangeScope::All).await?;
1799 assert_eq!(set.files.len(), 3);
1800 Ok(())
1801 }
1802
1803 #[tokio::test]
1804 async fn delete_is_classified() -> TestResult {
1805 let repo = repo()?;
1806 fs::remove_file(repo.path().join("tracked.txt"))?;
1807 let set = collect(repo.path(), ChangeScope::Unstaged).await?;
1808 assert_eq!(set.files[0].status, ChangedFileStatus::Deleted);
1809 Ok(())
1810 }
1811
1812 #[tokio::test]
1813 async fn rename_is_classified() -> TestResult {
1814 let repo = repo()?;
1815 git(repo.path(), &["mv", "tracked.txt", "renamed.txt"])?;
1816 let set = collect(repo.path(), ChangeScope::Staged).await?;
1817 assert_eq!(set.files[0].status, ChangedFileStatus::Renamed);
1818 Ok(())
1819 }
1820
1821 #[tokio::test]
1822 async fn compare_scope_uses_merge_base() -> TestResult {
1823 let repo = repo()?;
1824 let initial = String::from_utf8(
1825 StdCommand::new("git")
1826 .arg("-C")
1827 .arg(repo.path())
1828 .args(["rev-parse", "HEAD"])
1829 .output()?
1830 .stdout,
1831 )?;
1832 fs::write(repo.path().join("later.txt"), "later\n")?;
1833 git(repo.path(), &["add", "later.txt"])?;
1834 git(repo.path(), &["commit", "-qm", "later"])?;
1835 let set = collect(
1836 repo.path(),
1837 ChangeScope::Compare {
1838 reference: initial.trim().to_owned(),
1839 },
1840 )
1841 .await?;
1842 assert_eq!(set.files.len(), 1);
1843 Ok(())
1844 }
1845
1846 #[tokio::test]
1847 async fn commit_scope_reports_one_commit() -> TestResult {
1848 let repo = repo()?;
1849 fs::write(repo.path().join("later.txt"), "later\n")?;
1850 git(repo.path(), &["add", "later.txt"])?;
1851 git(repo.path(), &["commit", "-qm", "later"])?;
1852 let sha = String::from_utf8(
1853 StdCommand::new("git")
1854 .arg("-C")
1855 .arg(repo.path())
1856 .args(["rev-parse", "HEAD"])
1857 .output()?
1858 .stdout,
1859 )?;
1860 let set = collect(
1861 repo.path(),
1862 ChangeScope::Commit {
1863 sha: sha.trim().to_owned(),
1864 },
1865 )
1866 .await?;
1867 assert_eq!(set.files.len(), 1);
1868 Ok(())
1869 }
1870
1871 #[tokio::test]
1872 async fn range_scope_reports_endpoint_difference() -> TestResult {
1873 let repo = repo()?;
1874 let base = String::from_utf8(
1875 StdCommand::new("git")
1876 .arg("-C")
1877 .arg(repo.path())
1878 .args(["rev-parse", "HEAD"])
1879 .output()?
1880 .stdout,
1881 )?;
1882 fs::write(repo.path().join("later.txt"), "later\n")?;
1883 git(repo.path(), &["add", "later.txt"])?;
1884 git(repo.path(), &["commit", "-qm", "later"])?;
1885 let head = String::from_utf8(
1886 StdCommand::new("git")
1887 .arg("-C")
1888 .arg(repo.path())
1889 .args(["rev-parse", "HEAD"])
1890 .output()?
1891 .stdout,
1892 )?;
1893 let set = collect(
1894 repo.path(),
1895 ChangeScope::Range {
1896 base: base.trim().to_owned(),
1897 head: head.trim().to_owned(),
1898 },
1899 )
1900 .await?;
1901 assert_eq!(set.files.len(), 1);
1902 Ok(())
1903 }
1904
1905 #[tokio::test]
1906 async fn pull_request_scope_is_rejected_locally() -> TestResult {
1907 let repo = repo()?;
1908 let result = collect(
1909 repo.path(),
1910 ChangeScope::PullRequest {
1911 provider: "github".to_owned(),
1912 number: 1,
1913 },
1914 )
1915 .await;
1916 assert!(matches!(result, Err(ChangeError::PullRequestUnsupported)));
1917 Ok(())
1918 }
1919
1920 #[tokio::test]
1921 async fn cancelled_request_fails_before_spawn() -> TestResult {
1922 let repo = repo()?;
1923 let token = CancellationToken::new();
1924 token.cancel();
1925 let result = GitCliChangeProvider::new()
1926 .changes(&request(repo.path(), ChangeScope::All), &token)
1927 .await;
1928 assert!(matches!(result, Err(ChangeError::Cancelled)));
1929 Ok(())
1930 }
1931
1932 #[tokio::test]
1933 async fn output_cap_is_enforced() -> TestResult {
1934 let repo = repo()?;
1935 fs::write(repo.path().join("large.txt"), "x".repeat(4096))?;
1936 git(repo.path(), &["add", "large.txt"])?;
1937 let provider = GitCliChangeProvider::with_limits("git", Duration::from_secs(5), 64, 1024);
1938 let result = provider
1939 .changes(
1940 &request(repo.path(), ChangeScope::Staged),
1941 &CancellationToken::new(),
1942 )
1943 .await;
1944 assert!(matches!(result, Err(ChangeError::OutputLimit { .. })));
1945 Ok(())
1946 }
1947
1948 #[cfg(unix)]
1949 #[tokio::test]
1950 async fn linked_worktree_uses_shared_common_directory() -> TestResult {
1951 let repo = repo()?;
1952 let linked_parent = tempfile::tempdir()?;
1953 let linked = linked_parent.path().join("linked");
1954 let linked_text = linked.to_string_lossy().into_owned();
1955 git(
1956 repo.path(),
1957 &["worktree", "add", "-q", "-b", "linked-test", &linked_text],
1958 )?;
1959 let set = collect(&linked, ChangeScope::All).await?;
1960 assert_ne!(set.worktree.bytes, set.git_common_dir.bytes);
1961 Ok(())
1962 }
1963}