1use std::path::Path;
32
33use kernel::{FileChange, GitOid};
34
35use crate::changes::collect;
36use crate::error::StorageError;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct Drift {
41 pub ahead: u64,
42 pub behind: u64,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct WorkingStatus {
48 pub branch: Option<String>,
51 pub drift: Option<Drift>,
54 pub files: Vec<FileChange>,
58}
59
60fn git_err(op: &'static str) -> impl FnOnce(git2::Error) -> StorageError {
61 move |err| StorageError::Checkpoint {
62 op,
63 detail: err.message().to_owned(),
64 }
65}
66
67pub fn working_status(
85 city_root: &Path,
86 scope: Option<&str>,
87 base: Option<GitOid>,
88) -> Result<WorkingStatus, StorageError> {
89 let repo = git2::Repository::open(city_root).map_err(git_err("open the city repository"))?;
90 let head = match repo.head() {
91 Ok(found) => Some(found),
92 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => None,
94 Err(err) => return Err(git_err("read the repository head")(err)),
95 };
96 let branch = head
97 .as_ref()
98 .filter(|found| found.is_branch())
99 .and_then(|found| found.shorthand().ok())
100 .map(str::to_owned);
101 let commit = match base {
102 Some(oid) => {
103 let parsed =
104 git2::Oid::from_str(&oid.to_string()).map_err(git_err("parse a checkpoint"))?;
105 Some(
106 repo.find_commit(parsed)
107 .map_err(git_err("find a checkpoint"))?,
108 )
109 }
110 None => match &head {
111 Some(found) => Some(
112 found
113 .peel_to_commit()
114 .map_err(git_err("find the head commit"))?,
115 ),
116 None => None,
117 },
118 };
119 let tree = match &commit {
120 Some(found) => Some(found.tree().map_err(git_err("read a checkpoint tree"))?),
121 None => None,
122 };
123 let mut options = git2::DiffOptions::new();
124 options.context_lines(0);
125 options.include_untracked(true);
126 options.recurse_untracked_dirs(true);
127 if let Some(scope) = scope {
128 options.pathspec(scope);
129 }
130 let mut diff = repo
131 .diff_tree_to_workdir_with_index(tree.as_ref(), Some(&mut options))
132 .map_err(git_err("compare the working tree with the head"))?;
133 diff.find_similar(None)
134 .map_err(git_err("look for renames"))?;
135 Ok(WorkingStatus {
136 branch: branch.clone(),
137 drift: drift_of(&repo, branch.as_deref())?,
138 files: collect(&diff)?,
139 })
140}
141
142fn drift_of(repo: &git2::Repository, branch: Option<&str>) -> Result<Option<Drift>, StorageError> {
148 let Some(name) = branch else {
149 return Ok(None);
150 };
151 let Ok(local) = repo.find_branch(name, git2::BranchType::Local) else {
152 return Ok(None);
153 };
154 let Ok(upstream) = local.upstream() else {
155 return Ok(None);
156 };
157 let (Some(here), Some(there)) = (local.get().target(), upstream.get().target()) else {
158 return Ok(None);
159 };
160 let (ahead, behind) = repo
161 .graph_ahead_behind(here, there)
162 .map_err(git_err("measure the drift from the upstream"))?;
163 Ok(Some(Drift {
164 ahead: u64::try_from(ahead).unwrap_or(u64::MAX),
165 behind: u64::try_from(behind).unwrap_or(u64::MAX),
166 }))
167}
168
169#[cfg(test)]
170#[allow(
171 clippy::unwrap_used,
172 clippy::expect_used,
173 clippy::panic,
174 clippy::indexing_slicing,
175 reason = "test code"
176)]
177mod tests {
178 use super::*;
179 use kernel::{How, TimeMs};
180
181 use crate::checkpoint::Checkpoint;
182
183 fn oid_of(payload: &kernel::Payload) -> GitOid {
184 let raw = serde_json::to_value(payload).unwrap()["oid"]
185 .as_str()
186 .unwrap()
187 .to_owned();
188 GitOid::parse(&raw).expect("a checkpoint names a git object")
189 }
190
191 fn write(root: &Path, rel: &str, body: &str) {
192 let path = root.join(rel);
193 if let Some(parent) = path.parent() {
194 std::fs::create_dir_all(parent).unwrap();
195 }
196 std::fs::write(path, body).unwrap();
197 }
198
199 fn resident() -> crate::checkpoint::Provenance {
200 crate::checkpoint::Provenance::new(
201 kernel::RunId::CITY,
202 kernel::Address::parse("lab/parser").unwrap(),
203 kernel::B3Hash::digest(b"a city"),
204 crate::checkpoint::ModelChoice {
205 id: "test-model".to_owned(),
206 effort: None,
207 },
208 )
209 }
210
211 #[test]
214 fn a_file_written_since_the_last_checkpoint_is_listed_untracked_included() {
215 let dir = tempfile::tempdir().unwrap();
216 let root = dir.path();
217 write(root, "lab/lex.rs", "one\n");
218 let mut checkpoint = Checkpoint::open(root).unwrap();
219 let base = oid_of(
220 &checkpoint
221 .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
222 .unwrap(),
223 );
224
225 write(root, "lab/lex.rs", "one\ntwo\n");
226 write(root, "lab/fresh.rs", "new\n");
227 let status = working_status(root, Some("lab"), Some(base)).unwrap();
228 let paths: Vec<&str> = status.files.iter().map(|row| row.path.as_str()).collect();
229 assert_eq!(paths, vec!["lab/fresh.rs", "lab/lex.rs"]);
230 assert_eq!(status.files[0].how, How::Added);
231 assert_eq!(status.files[1].how, How::Modified);
232 }
233
234 #[test]
237 fn the_scope_keeps_another_buildings_work_out_of_the_answer() {
238 let dir = tempfile::tempdir().unwrap();
239 let root = dir.path();
240 write(root, "lab/lex.rs", "one\n");
241 let mut checkpoint = Checkpoint::open(root).unwrap();
242 let base = oid_of(
243 &checkpoint
244 .wave_pre(&["lab".to_owned()], TimeMs::new(1), &resident())
245 .unwrap(),
246 );
247
248 write(root, "hall/Notes.md", "elsewhere\n");
249 assert!(
250 working_status(root, Some("lab"), Some(base))
251 .unwrap()
252 .files
253 .is_empty()
254 );
255 let whole = working_status(root, None, Some(base)).unwrap();
256 assert_eq!(
257 whole
258 .files
259 .iter()
260 .map(|row| row.path.as_str())
261 .collect::<Vec<_>>(),
262 vec!["hall/Notes.md"]
263 );
264 }
265
266 #[test]
269 fn a_branch_with_no_upstream_reports_no_drift_rather_than_zero() {
270 let dir = tempfile::tempdir().unwrap();
271 let root = dir.path();
272 write(root, "lab/lex.rs", "one\n");
273 let mut checkpoint = Checkpoint::open(root).unwrap();
274 checkpoint
275 .ensure_base(&["lab".to_owned()], TimeMs::new(1), &resident())
276 .unwrap();
277 let status = working_status(root, None, None).unwrap();
278 assert!(status.branch.is_some(), "{status:?}");
279 assert_eq!(status.drift, None);
280 }
281
282 #[test]
285 fn a_city_with_no_commit_reports_its_files_rather_than_failing() {
286 let dir = tempfile::tempdir().unwrap();
287 let root = dir.path();
288 write(root, "lab/lex.rs", "one\n");
289 Checkpoint::open(root).unwrap();
290 let status = working_status(root, None, None).unwrap();
291 assert_eq!(status.branch, None);
292 assert_eq!(
293 status
294 .files
295 .iter()
296 .map(|row| row.how.clone())
297 .collect::<Vec<_>>(),
298 vec![How::Added]
299 );
300 }
301}