Skip to main content

code_system_graph_core/
changes.rs

1//! Bounded, cancellable local Git change discovery and commit gating.
2
3use 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/// Selects the repository state represented by a [`ChangeSet`].
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(tag = "kind", rename_all = "snake_case")]
27pub enum ChangeScope {
28    /// Tracked worktree changes not present in the index.
29    Unstaged,
30    /// Changes currently present in the index.
31    Staged,
32    /// Staged, tracked worktree, and untracked changes as separate layers.
33    All,
34    /// Changes from the merge base of a validated ref to `HEAD`.
35    Compare {
36        /// Branch, tag, or other non-expression Git ref.
37        reference: String,
38    },
39    /// The changes introduced by one full hexadecimal commit object ID.
40    Commit {
41        /// Full SHA-1 or SHA-256 commit object ID.
42        sha: String,
43    },
44    /// Changes between two validated refs, equivalent to `base..head`.
45    Range {
46        /// Older endpoint.
47        base: String,
48        /// Newer endpoint.
49        head: String,
50    },
51    /// A provider-specific pull request.
52    PullRequest {
53        /// External provider identifier, such as `github`.
54        provider: String,
55        /// Provider-local pull-request number.
56        number: u64,
57    },
58}
59
60/// Identifies the layer from which a changed file was discovered.
61#[derive(
62    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
63)]
64#[serde(rename_all = "snake_case")]
65pub enum ChangeSourceLayer {
66    /// The Git index.
67    Staged,
68    /// Tracked files in the working tree.
69    Worktree,
70    /// Untracked worktree entries.
71    Untracked,
72    /// A committed tree comparison.
73    Commit,
74}
75
76/// Git-level classification of a changed file.
77#[derive(
78    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
79)]
80#[serde(rename_all = "snake_case")]
81pub enum ChangedFileStatus {
82    /// A newly added tracked file.
83    Added,
84    /// A modified tracked file.
85    Modified,
86    /// A deleted tracked file.
87    Deleted,
88    /// A renamed tracked file.
89    Renamed,
90    /// A copied tracked file.
91    Copied,
92    /// An untracked file whose body was not read.
93    Untracked,
94}
95
96/// Kind of one changed line in a zero-context hunk.
97#[derive(
98    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
99)]
100#[serde(rename_all = "snake_case")]
101pub enum ChangedLineKind {
102    /// A line removed from the old side.
103    Removed,
104    /// A line added to the new side.
105    Added,
106}
107
108/// Position-only changed-line evidence.
109///
110/// Source text is deliberately excluded so a persisted [`ChangeSet`] cannot retain diff bodies.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
112pub struct ChangedLine {
113    /// Whether this line was added or removed.
114    pub kind: ChangedLineKind,
115    /// One-based line on the old side, when applicable.
116    pub old_line: Option<u32>,
117    /// One-based line on the new side, when applicable.
118    pub new_line: Option<u32>,
119}
120
121/// One zero-context unified-diff hunk.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
123pub struct ChangeHunk {
124    /// One-based old-side starting line.
125    pub old_start: u32,
126    /// Number of old-side lines covered.
127    pub old_count: u32,
128    /// One-based new-side starting line.
129    pub new_start: u32,
130    /// Number of new-side lines covered.
131    pub new_count: u32,
132    /// Changed line positions, without source text.
133    pub lines: Vec<ChangedLine>,
134}
135
136/// One changed file, preserving native path bytes where the platform permits it.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
138pub struct ChangedFile {
139    /// Git change classification.
140    pub status: ChangedFileStatus,
141    /// Original repository-relative path, when one exists.
142    pub old_path: Option<NativePath>,
143    /// Resulting repository-relative path, when one exists.
144    pub new_path: Option<NativePath>,
145    /// Whether Git reported a binary numstat.
146    pub binary: bool,
147    /// Position-only zero-context hunks.
148    pub hunks: Vec<ChangeHunk>,
149    /// State layer that supplied this record.
150    pub source: ChangeSourceLayer,
151}
152
153/// Version map for analyzers whose output is tied to a change set.
154pub type AnalyzerVersions = BTreeMap<String, String>;
155
156/// Immutable, fingerprinted description of a repository change.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
158pub struct ChangeSet {
159    /// Stable registered repository identity.
160    pub repo_id: RepoId,
161    /// Stable concrete checkout identity.
162    pub checkout_id: CheckoutId,
163    /// Canonical, lossless worktree root.
164    pub worktree: NativePath,
165    /// Canonical, lossless common Git directory.
166    pub git_common_dir: NativePath,
167    /// Requested scope.
168    pub scope: ChangeScope,
169    /// Symbolic checkout `HEAD`, when attached.
170    pub checkout_head_ref: Option<String>,
171    /// Commit currently checked out before applying the requested scope.
172    pub checkout_head_sha: String,
173    /// Scope base ref or object ID, when applicable.
174    pub base_ref: Option<String>,
175    /// Scope head ref or object ID, when applicable.
176    pub head_ref: Option<String>,
177    /// Resolved commit for the scope head.
178    pub head_sha: String,
179    /// BLAKE3 hash of exact bounded staged Git output.
180    pub staged_hash: String,
181    /// BLAKE3 hash of exact bounded tracked and untracked worktree output.
182    pub worktree_hash: String,
183    /// BLAKE3 fingerprint of exact bounded Git output and identity metadata.
184    pub exact_diff_fingerprint: String,
185    /// Workspace manifest fingerprint used by analysis.
186    pub workspace_manifest_hash: String,
187    /// Contract registry fingerprint used by analysis.
188    pub contract_registry_hash: String,
189    /// Analyzer names and exact versions used by analysis.
190    pub analyzer_versions: AnalyzerVersions,
191    /// Deterministically ordered changed files.
192    pub files: Vec<ChangedFile>,
193}
194
195/// Input required to collect one local change set.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197pub struct ChangeRequest {
198    /// Registered repository identity.
199    pub repo_id: RepoId,
200    /// Concrete checkout identity.
201    pub checkout_id: CheckoutId,
202    /// Any path accepted by `git -C`, normally the expected worktree root.
203    pub worktree: PathBuf,
204    /// State to collect.
205    pub scope: ChangeScope,
206    /// Current workspace manifest fingerprint.
207    pub workspace_manifest_hash: String,
208    /// Current contract registry fingerprint.
209    pub contract_registry_hash: String,
210    /// Current analyzer versions.
211    pub analyzer_versions: AnalyzerVersions,
212}
213
214/// Read-only source of repository changes.
215#[async_trait]
216pub trait ChangeProvider: Send + Sync {
217    /// Collects a bounded and fingerprinted change set.
218    ///
219    /// # Errors
220    ///
221    /// Returns [`ChangeError`] for invalid input, unsupported scopes, Git failures, cancellation,
222    /// timeouts, malformed output, and output-budget exhaustion.
223    async fn changes(
224        &self,
225        request: &ChangeRequest,
226        cancellation: &CancellationToken,
227    ) -> Result<ChangeSet, ChangeError>;
228}
229
230/// Configuration for the production local Git provider.
231#[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    /// Creates a provider using `git` from the process search path and conservative budgets.
252    #[must_use]
253    pub fn new() -> Self {
254        Self::default()
255    }
256
257    /// Creates a provider with explicit executable and process budgets.
258    ///
259    /// Zero output limits are rejected when [`ChangeProvider::changes`] is called.
260    #[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/// Current fingerprints used to determine whether analysis remains valid.
610#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
611pub struct ChangeValidityInput {
612    /// Current repository identity.
613    pub repo_id: RepoId,
614    /// Current checkout identity.
615    pub checkout_id: CheckoutId,
616    /// Current canonical worktree.
617    pub worktree: NativePath,
618    /// Current canonical common Git directory.
619    pub git_common_dir: NativePath,
620    /// Current checked-out commit.
621    pub checkout_head_sha: String,
622    /// Current staged output hash.
623    pub staged_hash: String,
624    /// Current worktree output hash.
625    pub worktree_hash: String,
626    /// Current exact diff fingerprint.
627    pub exact_diff_fingerprint: String,
628    /// Current workspace manifest hash.
629    pub workspace_manifest_hash: String,
630    /// Current contract registry hash.
631    pub contract_registry_hash: String,
632    /// Current analyzer versions.
633    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/// Machine-readable cause of stale change analysis.
655#[derive(
656    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
657)]
658#[serde(rename_all = "snake_case")]
659pub enum StaleReason {
660    /// Repository registration changed.
661    RepositoryChanged,
662    /// Checkout identity changed.
663    CheckoutChanged,
664    /// Canonical worktree changed.
665    WorktreeChanged,
666    /// Common Git directory changed.
667    GitCommonDirectoryChanged,
668    /// Checked-out commit changed.
669    HeadChanged,
670    /// Git index changed.
671    StagedChangesChanged,
672    /// Tracked or untracked worktree state changed.
673    WorktreeChangesChanged,
674    /// Exact diff fingerprint changed.
675    ExactDiffChanged,
676    /// Workspace manifest changed.
677    WorkspaceManifestChanged,
678    /// Contract registry changed.
679    ContractRegistryChanged,
680    /// Analyzer version set changed.
681    AnalyzerVersionsChanged,
682}
683
684/// Result of validating a previously analyzed change set.
685#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
686#[serde(tag = "status", rename_all = "snake_case")]
687pub enum ChangeValidity {
688    /// Every relevant fingerprint still matches.
689    Valid,
690    /// One or more inputs changed and analysis must not be reused.
691    Stale {
692        /// Deterministically ordered stale causes.
693        reasons: Vec<StaleReason>,
694        /// Safe operator action.
695        remediation: String,
696    },
697}
698
699/// Compares every analysis-relevant identity and fingerprint.
700#[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        &current.repo_id,
709        StaleReason::RepositoryChanged,
710        &mut reasons,
711    );
712    compare(
713        &change_set.checkout_id,
714        &current.checkout_id,
715        StaleReason::CheckoutChanged,
716        &mut reasons,
717    );
718    compare(
719        &change_set.worktree,
720        &current.worktree,
721        StaleReason::WorktreeChanged,
722        &mut reasons,
723    );
724    compare(
725        &change_set.git_common_dir,
726        &current.git_common_dir,
727        StaleReason::GitCommonDirectoryChanged,
728        &mut reasons,
729    );
730    compare(
731        &change_set.checkout_head_sha,
732        &current.checkout_head_sha,
733        StaleReason::HeadChanged,
734        &mut reasons,
735    );
736    compare(
737        &change_set.staged_hash,
738        &current.staged_hash,
739        StaleReason::StagedChangesChanged,
740        &mut reasons,
741    );
742    compare(
743        &change_set.worktree_hash,
744        &current.worktree_hash,
745        StaleReason::WorktreeChangesChanged,
746        &mut reasons,
747    );
748    compare(
749        &change_set.exact_diff_fingerprint,
750        &current.exact_diff_fingerprint,
751        StaleReason::ExactDiffChanged,
752        &mut reasons,
753    );
754    compare(
755        &change_set.workspace_manifest_hash,
756        &current.workspace_manifest_hash,
757        StaleReason::WorkspaceManifestChanged,
758        &mut reasons,
759    );
760    compare(
761        &change_set.contract_registry_hash,
762        &current.contract_registry_hash,
763        StaleReason::ContractRegistryChanged,
764        &mut reasons,
765    );
766    compare(
767        &change_set.analyzer_versions,
768        &current.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/// Git commit selection semantics modeled without executing a commit.
786#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
787#[serde(tag = "kind", rename_all = "snake_case")]
788pub enum CommitSelection {
789    /// Default `git commit`: commit the index exactly as represented.
790    Default,
791    /// `git commit -a`: stage and commit all tracked modifications and deletions.
792    AllTracked,
793    /// `git commit --only -- <pathspecs>`: commit only selected worktree paths.
794    Only {
795        /// Validated literal repository-relative pathspecs.
796        pathspecs: Vec<String>,
797    },
798    /// `git commit --include -- <pathspecs>`: add selected paths to the existing index.
799    Include {
800        /// Validated literal repository-relative pathspecs.
801        pathspecs: Vec<String>,
802    },
803}
804
805/// Commit command intent evaluated against an analyzed change set.
806#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
807pub struct CommitIntent {
808    /// Selection behavior requested by the caller.
809    pub selection: CommitSelection,
810}
811
812/// Explicit file/layer subset a modeled commit would select.
813#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
814pub struct CommitFileSelection {
815    /// Resulting path, or the old path for a deletion.
816    pub path: NativePath,
817    /// Included source layers.
818    pub layers: Vec<ChangeSourceLayer>,
819}
820
821/// Safety decision for a modeled commit command.
822#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
823pub struct CommitGate {
824    /// Whether the selected subset is explicit and unambiguous.
825    pub allowed: bool,
826    /// Explicit selected paths and layers.
827    pub selected: Vec<CommitFileSelection>,
828    /// Reasons the gate failed closed.
829    pub reasons: Vec<String>,
830}
831
832/// Evaluates commit selection semantics without mutating Git state.
833///
834/// # Errors
835///
836/// Returns [`ChangeError::InvalidPathspec`] when a pathspec could be interpreted as an option,
837/// revision expression, pathspec magic, glob, absolute path, or control-bearing input.
838pub 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/// Failures produced by local Git change discovery and commit modeling.
922#[derive(Debug, Error)]
923pub enum ChangeError {
924    /// Pull-request scopes require an external provider.
925    #[error("the local Git provider does not support pull-request scopes")]
926    PullRequestUnsupported,
927    /// A ref contains revision-expression or option syntax.
928    #[error("invalid Git ref `{0}`")]
929    InvalidRef(String),
930    /// A commit ID is not a full hexadecimal SHA-1 or SHA-256 object ID.
931    #[error("invalid full commit SHA `{0}`")]
932    InvalidSha(String),
933    /// A pathspec is unsafe or cannot be modeled literally.
934    #[error("invalid literal pathspec `{0}`")]
935    InvalidPathspec(String),
936    /// An output budget was configured as zero.
937    #[error("Git output limits must be greater than zero")]
938    InvalidLimit,
939    /// Git could not be started.
940    #[error("failed to spawn Git")]
941    Spawn {
942        /// Underlying process error.
943        #[source]
944        source: std::io::Error,
945    },
946    /// A required child pipe was unavailable.
947    #[error("Git child process did not expose a required pipe")]
948    MissingPipe,
949    /// Waiting for Git failed.
950    #[error("failed while waiting for Git")]
951    Wait {
952        /// Underlying process error.
953        #[source]
954        source: std::io::Error,
955    },
956    /// Reading bounded process output failed.
957    #[error("failed while reading bounded Git output")]
958    Read {
959        /// Underlying I/O error.
960        #[source]
961        source: std::io::Error,
962    },
963    /// A process reader task failed.
964    #[error("Git output reader task failed")]
965    ReaderTask {
966        /// Tokio task failure.
967        #[source]
968        source: tokio::task::JoinError,
969    },
970    /// Git exceeded the configured stream budget.
971    #[error("Git {stream} exceeded its {limit}-byte output limit")]
972    OutputLimit {
973        /// Stream whose budget was exhausted.
974        stream: &'static str,
975        /// Configured maximum bytes.
976        limit: usize,
977    },
978    /// Git returned a non-success status.
979    #[error("Git failed with status {status:?}: {stderr}")]
980    GitFailed {
981        /// Exit status code, if the platform supplied one.
982        status: Option<i32>,
983        /// Bounded, lossy diagnostic stderr.
984        stderr: String,
985    },
986    /// The operation exceeded its deadline.
987    #[error("Git operation timed out after {timeout:?}")]
988    Timeout {
989        /// Configured deadline.
990        timeout: Duration,
991    },
992    /// The caller cancelled the operation.
993    #[error("Git operation was cancelled")]
994    Cancelled,
995    /// Git identity metadata was unexpectedly non-UTF-8.
996    #[error("Git returned non-UTF-8 identity metadata")]
997    NonUtf8Metadata,
998    /// A canonical repository path could not be resolved.
999    #[error("failed to canonicalize `{path}`")]
1000    Canonicalize {
1001        /// Path being resolved.
1002        path: PathBuf,
1003        /// Underlying filesystem error.
1004        #[source]
1005        source: std::io::Error,
1006    },
1007    /// Git raw output was malformed or inconsistent.
1008    #[error("malformed bounded Git output: {0}")]
1009    MalformedGitOutput(String),
1010    /// Fingerprint identity serialization failed.
1011    #[error("failed to encode change identity")]
1012    FingerprintEncoding {
1013        /// Underlying serialization error.
1014        #[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}