1use super::service_fault::reach;
16use super::service_journal::{Intent, Recovery, list_intents, mark_done, recovery_for, write_intent, OPEN_EXTENSION};
17use super::service_protocol::{CommitRequest, FailureCode};
18use super::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
19use std::collections::{BTreeSet, HashMap};
20use std::path::{Path, PathBuf};
21
22pub const LANDING_ATTEMPTS: usize = 3;
24
25const INDEX_ATTEMPTS: usize = 5;
27
28const INDEX_PAUSE: std::time::Duration = std::time::Duration::from_millis(100);
30
31pub struct LandContext {
33 pub git: GitContext,
35 pub journal: PathBuf,
37 pub on_objects_written: Option<Box<dyn Fn() + Send + Sync>>,
39}
40
41#[derive(Clone, Debug, Eq, PartialEq)]
43pub enum Outcome {
44 Landed { commit: String },
46 RetryPrepare { base: String },
48 BranchMoved,
50}
51
52fn run(git: &GitContext, repository: &Path, arguments: &[&str], input: Option<Vec<u8>>) -> Result<GitOutput, FailureCode> {
53 let mut request: GitRequest = GitRequest::new(repository, arguments);
54 request.input = input;
55 let output: GitOutput = run_git(git, &request).map_err(|_| { return FailureCode::Unavailable; })?;
56 return Ok(output);
57}
58
59fn classify(output: &GitOutput) -> FailureCode {
60 let text: String = output.error_text().to_lowercase();
61 if text.contains("no space left") || text.contains("quota exceeded") {
62 return FailureCode::RepositoryFull;
63 }
64 if text.contains("read-only file system") || text.contains("permission denied") {
65 return FailureCode::RepositoryReadOnly;
66 }
67 if text.contains("index.lock") {
68 return FailureCode::IndexInUse;
69 }
70 return FailureCode::Unavailable;
71}
72
73fn checked(git: &GitContext, repository: &Path, arguments: &[&str], input: Option<Vec<u8>>) -> Result<String, FailureCode> {
74 let output: GitOutput = run(git, repository, arguments, input)?;
75 if !output.succeeded() {
76 return Err(classify(&output));
77 }
78 return Ok(String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string());
79}
80
81fn quoted(path: &str) -> String {
83 let mut text: String = String::from("\"");
84 for character in path.chars() {
85 match character {
86 '"' => text.push_str("\\\""),
87 '\\' => text.push_str("\\\\"),
88 other => text.push(other),
89 }
90 }
91 text.push('"');
92 return text;
93}
94
95fn overlaps(path: &str, changed: &str) -> bool {
97 return path == changed || path.starts_with(&format!("{changed}/")) || changed.starts_with(&format!("{path}/"));
98}
99
100fn import_stream(scratch: &str, old: &str, requests: &[&CommitRequest], amended_parents: &[String]) -> Vec<u8> {
103 let mut bytes: Vec<u8> = Vec::new();
104 for (index, request) in requests.iter().enumerate() {
105 bytes.extend(format!("commit {scratch}\nmark :{}\n", index + 1).as_bytes());
106 if let Some(author) = &request.author {
107 bytes.extend(format!("author {author}\n").as_bytes());
108 }
109 bytes.extend(format!("committer {}\ndata {}\n", request.committer, request.message.len()).as_bytes());
110 bytes.extend(request.message.as_bytes());
111 bytes.push(b'\n');
112 if let Some(tree) = request.tree.as_deref().filter(|_| { return request.amend; }) {
113 for (position, parent) in amended_parents.iter().enumerate() {
115 bytes.extend(format!("{} {parent}\n", if position == 0 { "from" } else { "merge" }).as_bytes());
116 }
117 bytes.extend(format!("M 040000 {tree} \"\"\n").as_bytes());
118 bytes.push(b'\n');
119 continue;
120 }
121 if index == 0 {
122 bytes.extend(format!("from {old}\n").as_bytes());
123 }
124 for change in &request.changes {
125 match &change.blob {
126 Some(blob) => bytes.extend(format!("M {:o} {blob} {}\n", change.mode, quoted(change.path.as_str())).as_bytes()),
127 None => bytes.extend(format!("D {}\n", quoted(change.path.as_str())).as_bytes()),
128 }
129 }
130 bytes.push(b'\n');
131 }
132 bytes.extend(b"done\n");
133 return bytes;
134}
135
136fn commits_from_marks(text: &str, count: usize) -> Option<Vec<String>> {
138 let mut commits: Vec<Option<String>> = vec![None; count];
139 for line in text.lines() {
140 let mut parts = line.split(' ');
141 let (Some(mark), Some(name)) = (parts.next(), parts.next()) else { continue };
142 let slot: usize = mark.trim_start_matches(':').parse::<usize>().ok()?;
143 if slot >= 1 && slot <= count {
144 commits[slot - 1] = Some(name.to_string());
145 }
146 }
147 return commits.into_iter().collect();
148}
149
150fn literal(path: &str) -> String {
152 return format!(":(top,literal){path}");
153}
154
155fn update_index(git: &GitContext, repository: &Path, new: &str, paths: &[String]) -> Result<(), FailureCode> {
157 if paths.is_empty() {
158 return Ok(());
160 }
161 let specs: Vec<String> = paths.iter().map(|path| { return literal(path.as_str()); }).collect();
162 let mut arguments: Vec<&str> = vec!["reset", "-q", new, "--"];
163 arguments.extend(specs.iter().map(String::as_str));
164 let mut last: FailureCode = FailureCode::Unavailable;
165 for attempt in 0..INDEX_ATTEMPTS {
166 match checked(git, repository, &arguments, None) {
167 Ok(_) => return Ok(()),
168 Err(code) => last = code,
169 }
170 if attempt + 1 < INDEX_ATTEMPTS {
171 std::thread::sleep(INDEX_PAUSE);
172 }
173 }
174 return Err(last);
175}
176
177pub fn land_batch(context: &LandContext, seq: u64, requests: &[CommitRequest]) -> Result<Vec<Outcome>, FailureCode> {
188 let first: &CommitRequest = requests.first().ok_or(FailureCode::Rejected)?;
189 if requests.iter().any(|request| {
190 return request.repository != first.repository || request.common_dir != first.common_dir || request.reference != first.reference;
191 }) {
192 return Err(FailureCode::Rejected);
193 }
194 let alone: bool = requests.iter().any(CommitRequest::lands_alone);
197 if alone && requests.len() != 1 {
198 return Err(FailureCode::Rejected);
199 }
200 let repository: &Path = first.repository.as_path();
201 let git: GitContext = GitContext { common_dir: Some(first.common_dir.clone()), ..context.git.clone() };
202 let scratch: String = format!("refs/cli-git-service/{seq}");
203 let mut outcomes: Vec<Outcome>;
204 let mut attempts_left: usize = LANDING_ATTEMPTS;
205 loop {
206 let old: String = checked(&git, repository, &["rev-parse", "--verify", first.reference.as_str()], None)?;
207 if alone && first.base != old {
208 return Ok(vec![Outcome::BranchMoved]);
209 }
210 let amended_parents: Vec<String> = if alone {
211 let listing: String = checked(&git, repository, &["rev-list", "--parents", "--max-count=1", first.base.as_str()], None)?;
212 listing.split_whitespace().skip(1).map(str::to_string).collect()
213 } else {
214 Vec::new()
215 };
216 let mut changed_since: HashMap<&str, BTreeSet<String>> = HashMap::new();
218 let mut landing: Vec<(usize, &CommitRequest)> = Vec::new();
219 outcomes = vec![Outcome::RetryPrepare { base: old.clone() }; requests.len()];
220 for (index, request) in requests.iter().enumerate() {
221 if request.base != old && !changed_since.contains_key(request.base.as_str()) {
222 let listing: String = checked(&git, repository, &["diff-tree", "-r", "--name-only", "-z", request.base.as_str(), old.as_str()], None)?;
223 changed_since.insert(request.base.as_str(), listing.split('\0').filter(|name| { return !name.is_empty(); }).map(str::to_string).collect());
224 }
225 let conflicts: bool = request.base != old
226 && request.changes.iter().any(|change| {
227 return changed_since.get(request.base.as_str()).is_some_and(|names| { return names.iter().any(|name| { return overlaps(change.path.as_str(), name.as_str()); }); });
228 });
229 if !conflicts {
230 landing.push((index, request));
231 }
232 }
233 if landing.is_empty() {
234 return Ok(outcomes);
235 }
236 let batch: Vec<&CommitRequest> = landing.iter().map(|(_, request)| { return *request; }).collect();
237 let (commits, new): (Vec<String>, String) = if let Some(built) = first.commit.as_deref() {
238 let expected: Vec<String> = if first.amend { amended_parents.clone() } else { vec![first.base.clone()] };
240 let listing: GitOutput = run(&git, repository, &["rev-list", "--parents", "--max-count=1", built], None)?;
241 let text: String = String::from_utf8_lossy(listing.stdout.as_slice()).into_owned();
242 let mut words = text.split_whitespace();
243 let named_itself: bool = listing.succeeded() && words.next() == Some(built);
244 if !named_itself || words.map(str::to_string).collect::<Vec<String>>() != expected {
245 return Err(FailureCode::Rejected);
246 }
247 (vec![built.to_string()], built.to_string())
248 } else {
249 let marks: PathBuf = context.journal.join(format!("marks-{seq}"));
250 let marks_argument: String = format!("--export-marks={}", marks.display());
251 checked(&git, repository, &["fast-import", "--quiet", "--done", marks_argument.as_str()], Some(import_stream(scratch.as_str(), old.as_str(), &batch, amended_parents.as_slice())))?;
252 let marks_text: String = std::fs::read_to_string(&marks).map_err(|_| { return FailureCode::Unavailable; })?;
253 let _marks_removed = std::fs::remove_file(&marks);
254 let made: Vec<String> = commits_from_marks(marks_text.as_str(), batch.len()).ok_or(FailureCode::Unavailable)?;
255 let last: String = made.last().cloned().ok_or(FailureCode::Unavailable)?;
256 (made, last)
257 };
258 reach("after-objects");
259 if let Some(seam) = &context.on_objects_written {
260 seam();
261 }
262 let paths: Vec<String> = batch.iter().flat_map(|request| { return request.changes.iter().map(|change| { return change.path.clone(); }); }).collect();
263 let intent: Intent = Intent {
264 seq,
265 repository: first.repository.clone(),
266 common_dir: first.common_dir.clone(),
267 reference: first.reference.clone(),
268 old: old.clone(),
269 new: new.clone(),
270 ids: batch.iter().map(|request| { return request.id.clone(); }).collect(),
271 commits: commits.clone(),
272 paths: paths.clone(),
273 };
274 write_intent(context.journal.as_path(), &intent).map_err(|error| { return storage_failure(&error); })?;
275 reach("after-intent");
276 let removal: String = if first.commit.is_some() { String::new() } else { format!("delete {scratch}\n") };
278 let transaction: String = format!("start\nupdate {} {new} {old}\n{removal}prepare\ncommit\n", first.reference);
279 let moved: GitOutput = run(&git, repository, &["update-ref", "--stdin"], Some(transaction.into_bytes()))?;
280 if !moved.succeeded() {
281 let _intent_removed = std::fs::remove_file(super::service_journal::batch_path(context.journal.as_path(), seq, OPEN_EXTENSION));
283 let _deleted = run(&git, repository, &["update-ref", "-d", scratch.as_str()], None);
284 attempts_left -= 1;
286 if attempts_left == 0 {
287 return Err(FailureCode::BranchKeepsChanging);
288 }
289 continue;
290 }
291 reach("after-ref");
292 if update_index(&git, repository, new.as_str(), &paths).is_ok() {
294 reach("after-index");
295 let _marked = mark_done(context.journal.as_path(), seq);
296 }
297 for (slot, (index, _)) in landing.iter().enumerate() {
298 outcomes[*index] = Outcome::Landed { commit: commits[slot].clone() };
299 }
300 return Ok(outcomes);
301 }
302}
303
304fn storage_failure(error: &std::io::Error) -> FailureCode {
305 return match error.raw_os_error() {
306 Some(code) if code == 28 || code == 122 => FailureCode::RepositoryFull,
307 Some(code) if code == 30 || code == 13 => FailureCode::RepositoryReadOnly,
308 _ => FailureCode::Unavailable,
309 };
310}
311
312#[derive(Clone, Debug, Eq, PartialEq)]
314pub struct RecoveryReport {
315 pub seq: u64,
317 pub decision: Recovery,
319}
320
321pub fn recover(context: &LandContext) -> Vec<RecoveryReport> {
332 let mut reports: Vec<RecoveryReport> = Vec::new();
333 for (path, parsed) in list_intents(context.journal.as_path(), OPEN_EXTENSION) {
334 let Some(intent) = parsed else {
335 let _removed = std::fs::remove_file(&path);
336 continue;
337 };
338 let git: GitContext = GitContext { common_dir: Some(intent.common_dir.clone()), ..context.git.clone() };
339 let current: String = checked(&git, intent.repository.as_path(), &["rev-parse", "--verify", intent.reference.as_str()], None).unwrap_or_default();
340 let ancestor: bool = run(&git, intent.repository.as_path(), &["merge-base", "--is-ancestor", intent.new.as_str(), current.as_str()], None)
341 .is_ok_and(|output| { return output.succeeded(); });
342 let decision: Recovery = recovery_for(&intent, current.as_str(), ancestor);
343 match decision {
344 Recovery::Drop => {
345 let _removed = std::fs::remove_file(&path);
346 }
347 Recovery::Finish => {
348 if update_index(&git, intent.repository.as_path(), intent.new.as_str(), &intent.paths).is_ok() {
349 let _marked = mark_done(context.journal.as_path(), intent.seq);
350 }
351 }
352 }
353 reports.push(RecoveryReport { seq: intent.seq, decision });
354 }
355 return reports;
356}
357
358#[cfg(test)]
360#[path = "service_land_tests.rs"]
361mod tests;