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}
49
50fn run(git: &GitContext, repository: &Path, arguments: &[&str], input: Option<Vec<u8>>) -> Result<GitOutput, FailureCode> {
51 let mut request: GitRequest = GitRequest::new(repository, arguments);
52 request.input = input;
53 let output: GitOutput = run_git(git, &request).map_err(|_| { return FailureCode::Unavailable; })?;
54 return Ok(output);
55}
56
57fn classify(output: &GitOutput) -> FailureCode {
58 let text: String = output.error_text().to_lowercase();
59 if text.contains("no space left") || text.contains("quota exceeded") {
60 return FailureCode::RepositoryFull;
61 }
62 if text.contains("read-only file system") || text.contains("permission denied") {
63 return FailureCode::RepositoryReadOnly;
64 }
65 if text.contains("index.lock") {
66 return FailureCode::IndexInUse;
67 }
68 return FailureCode::Unavailable;
69}
70
71fn checked(git: &GitContext, repository: &Path, arguments: &[&str], input: Option<Vec<u8>>) -> Result<String, FailureCode> {
72 let output: GitOutput = run(git, repository, arguments, input)?;
73 if !output.succeeded() {
74 return Err(classify(&output));
75 }
76 return Ok(String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string());
77}
78
79fn quoted(path: &str) -> String {
81 let mut text: String = String::from("\"");
82 for character in path.chars() {
83 match character {
84 '"' => text.push_str("\\\""),
85 '\\' => text.push_str("\\\\"),
86 other => text.push(other),
87 }
88 }
89 text.push('"');
90 return text;
91}
92
93fn overlaps(path: &str, changed: &str) -> bool {
95 return path == changed || path.starts_with(&format!("{changed}/")) || changed.starts_with(&format!("{path}/"));
96}
97
98fn import_stream(scratch: &str, old: &str, requests: &[&CommitRequest]) -> Vec<u8> {
100 let mut bytes: Vec<u8> = Vec::new();
101 for (index, request) in requests.iter().enumerate() {
102 bytes.extend(format!("commit {scratch}\nmark :{}\n", index + 1).as_bytes());
103 if let Some(author) = &request.author {
104 bytes.extend(format!("author {author}\n").as_bytes());
105 }
106 bytes.extend(format!("committer {}\ndata {}\n", request.committer, request.message.len()).as_bytes());
107 bytes.extend(request.message.as_bytes());
108 bytes.push(b'\n');
109 if index == 0 {
110 bytes.extend(format!("from {old}\n").as_bytes());
111 }
112 for change in &request.changes {
113 match &change.blob {
114 Some(blob) => bytes.extend(format!("M {:o} {blob} {}\n", change.mode, quoted(change.path.as_str())).as_bytes()),
115 None => bytes.extend(format!("D {}\n", quoted(change.path.as_str())).as_bytes()),
116 }
117 }
118 bytes.push(b'\n');
119 }
120 bytes.extend(b"done\n");
121 return bytes;
122}
123
124fn commits_from_marks(text: &str, count: usize) -> Option<Vec<String>> {
126 let mut commits: Vec<Option<String>> = vec![None; count];
127 for line in text.lines() {
128 let mut parts = line.split(' ');
129 let (Some(mark), Some(name)) = (parts.next(), parts.next()) else { continue };
130 let slot: usize = mark.trim_start_matches(':').parse::<usize>().ok()?;
131 if slot >= 1 && slot <= count {
132 commits[slot - 1] = Some(name.to_string());
133 }
134 }
135 return commits.into_iter().collect();
136}
137
138fn literal(path: &str) -> String {
140 return format!(":(top,literal){path}");
141}
142
143fn update_index(git: &GitContext, repository: &Path, new: &str, paths: &[String]) -> Result<(), FailureCode> {
145 if paths.is_empty() {
146 return Ok(());
148 }
149 let specs: Vec<String> = paths.iter().map(|path| { return literal(path.as_str()); }).collect();
150 let mut arguments: Vec<&str> = vec!["reset", "-q", new, "--"];
151 arguments.extend(specs.iter().map(String::as_str));
152 let mut last: FailureCode = FailureCode::Unavailable;
153 for attempt in 0..INDEX_ATTEMPTS {
154 match checked(git, repository, &arguments, None) {
155 Ok(_) => return Ok(()),
156 Err(code) => last = code,
157 }
158 if attempt + 1 < INDEX_ATTEMPTS {
159 std::thread::sleep(INDEX_PAUSE);
160 }
161 }
162 return Err(last);
163}
164
165pub fn land_batch(context: &LandContext, seq: u64, requests: &[CommitRequest]) -> Result<Vec<Outcome>, FailureCode> {
176 let first: &CommitRequest = requests.first().ok_or(FailureCode::Rejected)?;
177 if requests.iter().any(|request| {
178 return request.repository != first.repository || request.common_dir != first.common_dir || request.reference != first.reference;
179 }) {
180 return Err(FailureCode::Rejected);
181 }
182 let repository: &Path = first.repository.as_path();
183 let git: GitContext = GitContext { common_dir: Some(first.common_dir.clone()), ..context.git.clone() };
184 let scratch: String = format!("refs/cli-git-service/{seq}");
185 let mut outcomes: Vec<Outcome>;
186 let mut attempts_left: usize = LANDING_ATTEMPTS;
187 loop {
188 let old: String = checked(&git, repository, &["rev-parse", "--verify", first.reference.as_str()], None)?;
189 let mut changed_since: HashMap<&str, BTreeSet<String>> = HashMap::new();
191 let mut landing: Vec<(usize, &CommitRequest)> = Vec::new();
192 outcomes = vec![Outcome::RetryPrepare { base: old.clone() }; requests.len()];
193 for (index, request) in requests.iter().enumerate() {
194 if request.base != old && !changed_since.contains_key(request.base.as_str()) {
195 let listing: String = checked(&git, repository, &["diff-tree", "-r", "--name-only", "-z", request.base.as_str(), old.as_str()], None)?;
196 changed_since.insert(request.base.as_str(), listing.split('\0').filter(|name| { return !name.is_empty(); }).map(str::to_string).collect());
197 }
198 let conflicts: bool = request.base != old
199 && request.changes.iter().any(|change| {
200 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()); }); });
201 });
202 if !conflicts {
203 landing.push((index, request));
204 }
205 }
206 if landing.is_empty() {
207 return Ok(outcomes);
208 }
209 let batch: Vec<&CommitRequest> = landing.iter().map(|(_, request)| { return *request; }).collect();
210 let marks: PathBuf = context.journal.join(format!("marks-{seq}"));
211 let marks_argument: String = format!("--export-marks={}", marks.display());
212 checked(&git, repository, &["fast-import", "--quiet", "--done", marks_argument.as_str()], Some(import_stream(scratch.as_str(), old.as_str(), &batch)))?;
213 let marks_text: String = std::fs::read_to_string(&marks).map_err(|_| { return FailureCode::Unavailable; })?;
214 let _marks_removed = std::fs::remove_file(&marks);
215 let commits: Vec<String> = commits_from_marks(marks_text.as_str(), batch.len()).ok_or(FailureCode::Unavailable)?;
216 let new: String = commits.last().cloned().ok_or(FailureCode::Unavailable)?;
217 reach("after-objects");
218 if let Some(seam) = &context.on_objects_written {
219 seam();
220 }
221 let paths: Vec<String> = batch.iter().flat_map(|request| { return request.changes.iter().map(|change| { return change.path.clone(); }); }).collect();
222 let intent: Intent = Intent {
223 seq,
224 repository: first.repository.clone(),
225 common_dir: first.common_dir.clone(),
226 reference: first.reference.clone(),
227 old: old.clone(),
228 new: new.clone(),
229 ids: batch.iter().map(|request| { return request.id.clone(); }).collect(),
230 commits: commits.clone(),
231 paths: paths.clone(),
232 };
233 write_intent(context.journal.as_path(), &intent).map_err(|error| { return storage_failure(&error); })?;
234 reach("after-intent");
235 let transaction: String = format!("start\nupdate {} {new} {old}\ndelete {scratch}\nprepare\ncommit\n", first.reference);
236 let moved: GitOutput = run(&git, repository, &["update-ref", "--stdin"], Some(transaction.into_bytes()))?;
237 if !moved.succeeded() {
238 let _intent_removed = std::fs::remove_file(super::service_journal::batch_path(context.journal.as_path(), seq, OPEN_EXTENSION));
240 let _deleted = run(&git, repository, &["update-ref", "-d", scratch.as_str()], None);
241 attempts_left -= 1;
242 if attempts_left == 0 {
243 return Err(FailureCode::BranchKeepsChanging);
244 }
245 continue;
246 }
247 reach("after-ref");
248 if update_index(&git, repository, new.as_str(), &paths).is_ok() {
250 reach("after-index");
251 let _marked = mark_done(context.journal.as_path(), seq);
252 }
253 for (slot, (index, _)) in landing.iter().enumerate() {
254 outcomes[*index] = Outcome::Landed { commit: commits[slot].clone() };
255 }
256 return Ok(outcomes);
257 }
258}
259
260fn storage_failure(error: &std::io::Error) -> FailureCode {
261 return match error.raw_os_error() {
262 Some(code) if code == 28 || code == 122 => FailureCode::RepositoryFull,
263 Some(code) if code == 30 || code == 13 => FailureCode::RepositoryReadOnly,
264 _ => FailureCode::Unavailable,
265 };
266}
267
268#[derive(Clone, Debug, Eq, PartialEq)]
270pub struct RecoveryReport {
271 pub seq: u64,
273 pub decision: Recovery,
275}
276
277pub fn recover(context: &LandContext) -> Vec<RecoveryReport> {
287 let mut reports: Vec<RecoveryReport> = Vec::new();
288 for (path, parsed) in list_intents(context.journal.as_path(), OPEN_EXTENSION) {
289 let Some(intent) = parsed else {
290 let _removed = std::fs::remove_file(&path);
291 continue;
292 };
293 let git: GitContext = GitContext { common_dir: Some(intent.common_dir.clone()), ..context.git.clone() };
294 let current: String = checked(&git, intent.repository.as_path(), &["rev-parse", "--verify", intent.reference.as_str()], None).unwrap_or_default();
295 let ancestor: bool = run(&git, intent.repository.as_path(), &["merge-base", "--is-ancestor", intent.new.as_str(), current.as_str()], None)
296 .is_ok_and(|output| { return output.succeeded(); });
297 let decision: Recovery = recovery_for(&intent, current.as_str(), ancestor);
298 match decision {
299 Recovery::Drop => {
300 let _removed = std::fs::remove_file(&path);
301 }
302 Recovery::Finish => {
303 if update_index(&git, intent.repository.as_path(), intent.new.as_str(), &intent.paths).is_ok() {
304 let _marked = mark_done(context.journal.as_path(), intent.seq);
305 }
306 }
307 }
308 reports.push(RecoveryReport { seq: intent.seq, decision });
309 }
310 return reports;
311}
312
313#[cfg(test)]
315#[path = "service_land_tests.rs"]
316mod tests;