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