Skip to main content

relay_knowledge/domain/code/repository/
scope_identity.rs

1use crate::identity::stable_hash64;
2
3const CODE_SNAPSHOT_FACT_VERSION: &str = "code-facts-js-ts-import-edges-v1-sbom-dependencies-v2-python-type-refs-v1-scope-compat-v1-workspace-imports-v1-generated-files-v1-web-routes-v1-syntax-failure-chunks-v1-bounded-config-chunks-v1-dense-source-windows-v1-c-composite-tags-v1-doc-block-owner-anchor-v2-bounded-type-doc-summary-v1-search-owner-v2-reference-search-groups-v2";
4
5/// Builds the stable source scope id for a Git snapshot partition.
6pub fn code_snapshot_scope_id(
7    repository_id: &str,
8    tree_hash: &str,
9    path_filters: &[String],
10    language_filters: &[String],
11) -> String {
12    let mut input = Vec::new();
13    append_hash_part(&mut input, "git_snapshot");
14    append_hash_part(&mut input, repository_id);
15    append_hash_part(&mut input, tree_hash);
16    append_hash_list(&mut input, path_filters);
17    append_hash_list(&mut input, language_filters);
18    append_hash_part(&mut input, CODE_SNAPSHOT_FACT_VERSION);
19
20    format!("git_snapshot:{:016x}", stable_hash64(&input))
21}
22
23/// Builds a scope identity that includes workspace-detection semantics when
24/// those semantics can add persisted workspace graph facts.
25pub fn code_snapshot_scope_id_with_workspace_detection(
26    repository_id: &str,
27    tree_hash: &str,
28    path_filters: &[String],
29    language_filters: &[String],
30    config: &super::super::workspace::CodeWorkspaceDetectionConfig,
31) -> String {
32    let base = code_snapshot_scope_id(repository_id, tree_hash, path_filters, language_filters);
33    workspace_detection_mask(config)
34        .map_or(base.clone(), |mask| format!("{base}:workspace-v1:{mask}"))
35}
36
37/// Accepts every canonical supported workspace configuration while still
38/// rejecting scopes from older code-fact versions or unrelated identities.
39pub fn code_snapshot_scope_matches_identity(
40    repository_id: &str,
41    tree_hash: &str,
42    path_filters: &[String],
43    language_filters: &[String],
44    source_scope: &str,
45) -> bool {
46    let base = code_snapshot_scope_id(repository_id, tree_hash, path_filters, language_filters);
47    parse_scope_identity(source_scope).is_some_and(|identity| identity.base == base)
48}
49
50/// Returns the workspace semantic encoded by a valid scope identity.
51/// `Some(None)` is the backward-compatible disabled identity; an enabled
52/// configuration, including mask zero, is `Some(Some(mask))`.
53pub fn code_snapshot_scope_workspace_semantic(
54    repository_id: &str,
55    tree_hash: &str,
56    path_filters: &[String],
57    language_filters: &[String],
58    source_scope: &str,
59) -> Option<Option<u8>> {
60    let expected_base =
61        code_snapshot_scope_id(repository_id, tree_hash, path_filters, language_filters);
62    let identity = parse_scope_identity(source_scope)?;
63    (identity.base == expected_base).then_some(identity.workspace_mask)
64}
65
66fn workspace_detection_mask(
67    config: &super::super::workspace::CodeWorkspaceDetectionConfig,
68) -> Option<u8> {
69    if !config.enabled {
70        return None;
71    }
72    let formats = [
73        super::super::workspace::CodeMonorepoWorkspaceFormat::Pnpm,
74        super::super::workspace::CodeMonorepoWorkspaceFormat::GoModules,
75        super::super::workspace::CodeMonorepoWorkspaceFormat::CargoWorkspace,
76    ];
77    Some(
78        formats
79            .iter()
80            .enumerate()
81            .fold(0_u8, |mask, (index, format)| {
82                mask | (u8::from(config.supported_formats.contains(format)) << index)
83            }),
84    )
85}
86
87pub fn code_snapshot_scope_is_fact_versioned(source_scope: &str) -> bool {
88    parse_scope_identity(source_scope).is_some()
89}
90
91struct ParsedScopeIdentity {
92    base: String,
93    workspace_mask: Option<u8>,
94}
95
96fn parse_scope_identity(source_scope: &str) -> Option<ParsedScopeIdentity> {
97    let mut parts = source_scope.split(':');
98    if parts.next()? != "git_snapshot" {
99        return None;
100    }
101    let hash = parts.next()?;
102    if hash.len() != 16 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
103        return None;
104    }
105    let base = format!("git_snapshot:{hash}");
106    let Some(label) = parts.next() else {
107        return Some(ParsedScopeIdentity {
108            base,
109            workspace_mask: None,
110        });
111    };
112    if label != "workspace-v1" {
113        return None;
114    }
115    let encoded = parts.next()?;
116    if parts.next().is_some() {
117        return None;
118    }
119    let mask = encoded.parse::<u8>().ok()?;
120    if mask >= 8 || encoded != mask.to_string() {
121        return None;
122    }
123    Some(ParsedScopeIdentity {
124        base,
125        workspace_mask: Some(mask),
126    })
127}
128
129/// Returns the clean Git commit carried by a persisted snapshot identity.
130///
131/// Clean snapshots already store the commit SHA directly. Worktree overlays use
132/// `worktree:<base-commit>:<overlay-hash>` and deliberately resolve back to the
133/// clean base so a later commit reconciliation never treats dirty files as an
134/// incremental Git base. Filesystem identities are not Git commits.
135pub fn clean_git_commit_from_snapshot_identity(identity: &str) -> Option<&str> {
136    if identity.is_empty() || identity.starts_with("filesystem:") {
137        return None;
138    }
139    let Some(rest) = identity.strip_prefix("worktree:") else {
140        return Some(identity);
141    };
142    let (base_commit, overlay_hash) = rest.split_once(':')?;
143    (!base_commit.is_empty() && !overlay_hash.is_empty()).then_some(base_commit)
144}
145
146fn append_hash_list(input: &mut Vec<u8>, values: &[String]) {
147    input.extend_from_slice(&(values.len() as u64).to_le_bytes());
148    for value in values {
149        append_hash_part(input, value);
150    }
151}
152
153fn append_hash_part(input: &mut Vec<u8>, value: &str) {
154    input.extend_from_slice(&(value.len() as u64).to_le_bytes());
155    input.extend_from_slice(value.as_bytes());
156}
157
158#[cfg(test)]
159#[path = "scope_identity_tests.rs"]
160mod tests;