relay_knowledge/watcher/task_seed/
mod.rs1use std::path::{Path, PathBuf};
2
3use super::WatchedRepository;
4
5pub(super) struct ChangedPathSnapshot {
6 pub path: PathBuf,
7 pub content_hash: u64,
8}
9
10pub fn build_incremental_task_seed(
11 repository: &WatchedRepository,
12 changed_paths: &[PathBuf],
13 ref_selector: &str,
14 resolved_commit_sha: &str,
15 tree_hash: &str,
16 content_fingerprint: u64,
17 now_ms: u64,
18) -> Option<crate::storage::CodeIndexTaskSeed> {
19 if changed_paths.is_empty() {
20 return None;
21 }
22 let relative_paths = changed_path_labels(repository, changed_paths);
23 if relative_paths.is_empty() {
24 return None;
25 }
26 let path_hash = stable_path_fingerprint(&relative_paths);
27 let effective_ref = if ref_selector.trim().is_empty() {
28 "HEAD"
29 } else {
30 ref_selector
31 };
32 let task_resolved_commit = if resolved_commit_sha.trim().is_empty() {
33 effective_ref.to_owned()
34 } else {
35 resolved_commit_sha.to_owned()
36 };
37 let task_tree_hash = if tree_hash.trim().is_empty() {
38 format!("worktree:pending:{content_fingerprint:016x}")
39 } else {
40 tree_hash.to_owned()
41 };
42
43 let input_fingerprint = format!(
44 "worktree_overlay:{}:{}:{}:{path_hash:016x}:{content_fingerprint:016x}",
45 repository.repository_id, task_tree_hash, repository.source_scope,
46 );
47
48 let request = crate::domain::CodeIndexRequest {
49 repository: crate::domain::CodeRepositorySelector {
50 repository: repository.alias.clone(),
51 ref_selector: effective_ref.to_owned(),
52 path_filters: Vec::new(),
53 language_filters: Vec::new(),
54 },
55 mode: crate::domain::CodeIndexMode::WorktreeOverlay,
56 workspace_detection: Default::default(),
57 freshness_policy: crate::domain::FreshnessPolicy::WaitUntilFresh,
58 };
59 let mut payload = serde_json::to_value(&request).ok()?;
60 if let Some(object) = payload.as_object_mut() {
61 object.insert(
62 "watcher".to_owned(),
63 serde_json::json!({
64 "repository_id": repository.repository_id.clone(),
65 "changed_paths": relative_paths,
66 "content_fingerprint": format!("{content_fingerprint:016x}"),
67 }),
68 );
69 }
70
71 Some(crate::storage::CodeIndexTaskSeed {
72 repository_id: repository.repository_id.clone(),
73 alias: repository.alias.clone(),
74 ref_selector: effective_ref.to_owned(),
75 resolved_commit_sha: task_resolved_commit,
76 tree_hash: task_tree_hash,
77 source_scope: repository.source_scope.clone(),
78 path_filters: repository.path_filters.clone(),
79 language_filters: repository.language_filters.clone(),
80 mode: crate::domain::CodeIndexMode::WorktreeOverlay,
81 input_fingerprint,
82 resource_budget: crate::domain::CodeIndexResourceBudget::default(),
83 payload_json: serde_json::to_string(&payload).ok()?,
84 now_ms,
85 })
86}
87
88pub(super) fn changed_content_fingerprint(
89 repository: &WatchedRepository,
90 changes: &[&ChangedPathSnapshot],
91) -> u64 {
92 let mut entries = changes
93 .iter()
94 .filter_map(|change| {
95 let relative = change.path.strip_prefix(&repository.root).ok()?;
96 let label = path_label(relative)?;
97 Some((label, change.content_hash))
98 })
99 .collect::<Vec<_>>();
100 entries.sort();
101 entries.dedup();
102 stable_content_fingerprint(&entries)
103}
104
105pub(super) fn unreadable_path_fingerprint(path: &Path) -> u64 {
106 let label = path_label(path).unwrap_or_else(|| "<unreadable>".to_owned());
107 stable_content_fingerprint(&[(label, 0)])
108}
109
110fn changed_path_labels(repository: &WatchedRepository, changed_paths: &[PathBuf]) -> Vec<String> {
111 let mut labels = changed_paths
112 .iter()
113 .filter_map(|path| path.strip_prefix(&repository.root).ok())
114 .filter_map(path_label)
115 .collect::<Vec<_>>();
116 labels.sort();
117 labels.dedup();
118 labels
119}
120
121fn path_label(path: &Path) -> Option<String> {
122 let value = path
123 .to_string_lossy()
124 .replace(std::path::MAIN_SEPARATOR, "/");
125 (!value.is_empty()).then_some(value)
126}
127
128fn stable_path_fingerprint(paths: &[String]) -> u64 {
129 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
130 const FNV_PRIME: u64 = 0x100000001b3;
131
132 let mut hash = FNV_OFFSET_BASIS;
133 for path in paths {
134 for byte in path.as_bytes().iter().copied().chain([0]) {
135 hash ^= u64::from(byte);
136 hash = hash.wrapping_mul(FNV_PRIME);
137 }
138 }
139 hash
140}
141
142fn stable_content_fingerprint(entries: &[(String, u64)]) -> u64 {
143 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
144 const FNV_PRIME: u64 = 0x100000001b3;
145
146 let mut hash = FNV_OFFSET_BASIS;
147 for (path, content_hash) in entries {
148 for byte in path
149 .as_bytes()
150 .iter()
151 .copied()
152 .chain([0])
153 .chain(content_hash.to_le_bytes())
154 .chain([0])
155 {
156 hash ^= u64::from(byte);
157 hash = hash.wrapping_mul(FNV_PRIME);
158 }
159 }
160 hash
161}
162
163#[cfg(test)]
164#[path = "mod_tests.rs"]
165mod tests;