1use super::service_fault::reach;
16use super::service_head::head_names;
17use super::service_instance::instance_of;
18use super::service_pruned::batch_content_missing;
19use super::service_journal::{Intent, Recovery, list_intents, mark_done, recovery_for, write_intent, OPEN_EXTENSION};
20use super::service_index_lock::{Announce, IndexLock, acquire_noting_holder, remove_stale, try_acquire};
21use super::service_timing::LandTiming;
22use super::service_land_follow::{IndexPaths, build_index, classify, remove_conclusion_state, storage_failure};
23use super::service_protocol::{CommitRequest, FailureCode, is_zero_object};
24use super::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
25use std::collections::{BTreeSet, HashMap};
26use std::path::{Path, PathBuf};
27use std::time::Instant;
28
29pub const LANDING_ATTEMPTS: usize = 3;
31
32pub struct LandContext {
34 pub git: GitContext,
36 pub journal: PathBuf,
38 pub on_objects_written: Option<Box<dyn Fn() + Send + Sync>>,
40 pub index_wait: std::time::Duration,
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
46pub enum Outcome {
47 Landed { commit: String },
49 RetryPrepare { base: String },
51 BranchMoved,
53}
54
55fn run(git: &GitContext, repository: &Path, arguments: &[&str], input: Option<Vec<u8>>) -> Result<GitOutput, FailureCode> {
56 let mut request: GitRequest = GitRequest::new(repository, arguments);
57 request.input = input;
58 let output: GitOutput = run_git(git, &request).map_err(|_| { return FailureCode::Unavailable; })?;
59 return Ok(output);
60}
61
62fn checked(git: &GitContext, repository: &Path, arguments: &[&str], input: Option<Vec<u8>>) -> Result<String, FailureCode> {
63 let output: GitOutput = run(git, repository, arguments, input)?;
64 if !output.succeeded() {
65 return Err(classify(&output));
66 }
67 return Ok(String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string());
68}
69
70fn quoted(path: &str) -> String {
72 let mut text: String = String::from("\"");
73 for character in path.chars() {
74 match character {
75 '"' => text.push_str("\\\""),
76 '\\' => text.push_str("\\\\"),
77 other => text.push(other),
78 }
79 }
80 text.push('"');
81 return text;
82}
83
84fn overlaps(path: &str, changed: &str) -> bool {
86 return path == changed || path.starts_with(&format!("{changed}/")) || changed.starts_with(&format!("{path}/"));
87}
88
89fn import_stream(scratch: &str, old: &str, requests: &[&CommitRequest], amended_parents: &[String]) -> Vec<u8> {
92 let mut bytes: Vec<u8> = Vec::new();
93 for (index, request) in requests.iter().enumerate() {
94 bytes.extend(format!("commit {scratch}\nmark :{}\n", index + 1).as_bytes());
95 if let Some(author) = &request.author {
96 bytes.extend(format!("author {author}\n").as_bytes());
97 }
98 bytes.extend(format!("committer {}\ndata {}\n", request.committer, request.message.len()).as_bytes());
99 bytes.extend(request.message.as_bytes());
100 bytes.push(b'\n');
101 if let Some(tree) = request.tree.as_deref().filter(|_| { return request.amend; }) {
102 for (position, parent) in amended_parents.iter().enumerate() {
104 bytes.extend(format!("{} {parent}\n", if position == 0 { "from" } else { "merge" }).as_bytes());
105 }
106 bytes.extend(format!("M 040000 {tree} \"\"\n").as_bytes());
107 bytes.push(b'\n');
108 continue;
109 }
110 if index == 0 {
111 bytes.extend(format!("from {old}\n").as_bytes());
112 }
113 for change in &request.changes {
114 match &change.blob {
115 Some(blob) => bytes.extend(format!("M {:o} {blob} {}\n", change.mode, quoted(change.path.as_str())).as_bytes()),
116 None => bytes.extend(format!("D {}\n", quoted(change.path.as_str())).as_bytes()),
117 }
118 }
119 bytes.push(b'\n');
120 }
121 bytes.extend(b"done\n");
122 return bytes;
123}
124
125fn commits_from_marks(text: &str, count: usize) -> Option<Vec<String>> {
127 let mut commits: Vec<Option<String>> = vec![None; count];
128 for line in text.lines() {
129 let mut parts = line.split(' ');
130 let (Some(mark), Some(name)) = (parts.next(), parts.next()) else { continue };
131 let slot: usize = mark.trim_start_matches(':').parse::<usize>().ok()?;
132 if slot >= 1 && slot <= count {
133 commits[slot - 1] = Some(name.to_string());
134 }
135 }
136 return commits.into_iter().collect();
137}
138
139fn intent_paths(intent: &Intent) -> IndexPaths<'_> {
141 return if intent.derived { IndexPaths::Derived { old: intent.old.as_str() } } else { IndexPaths::Listed(intent.paths.as_slice()) };
142}
143
144fn follow_up(git: &GitContext, repository: &Path, intent: &Intent, lock: IndexLock) -> bool {
147 let cleaned: bool = intent.cleanup.as_ref().is_none_or(|cleanup| { return remove_conclusion_state(git, repository, cleanup); });
148 let Ok(built) = build_index(git, repository, intent.new.as_str(), intent_paths(intent), intent.index.as_path(), intent.seq) else {
150 return false;
151 };
152 return lock.release_with(built.as_path()).is_ok() && cleaned;
153}
154
155pub fn land_batch(context: &LandContext, seq: u64, requests: &[CommitRequest]) -> Result<Vec<Outcome>, FailureCode> {
166 return land_batch_announcing(context, seq, requests, &|_| {});
167}
168
169pub fn land_batch_announcing(context: &LandContext, seq: u64, requests: &[CommitRequest], announce: &dyn Fn(Announce)) -> Result<Vec<Outcome>, FailureCode> {
171 return land_batch_timed(context, seq, requests, announce, &mut LandTiming::default());
172}
173
174pub fn land_batch_timed(
197 context: &LandContext,
198 seq: u64,
199 requests: &[CommitRequest],
200 announce: &dyn Fn(Announce),
201 timing: &mut LandTiming,
202) -> Result<Vec<Outcome>, FailureCode> {
203 let first: &CommitRequest = requests.first().ok_or(FailureCode::Rejected)?;
204 if requests.iter().any(|request| {
205 return request.repository != first.repository || request.common_dir != first.common_dir || request.reference != first.reference;
206 }) {
207 return Err(FailureCode::Rejected);
208 }
209 let alone: bool = requests.iter().any(CommitRequest::lands_alone);
212 if alone && requests.len() != 1 {
213 return Err(FailureCode::Rejected);
214 }
215 let repository: &Path = first.repository.as_path();
216 let git: GitContext = GitContext { common_dir: Some(first.common_dir.clone()), ..context.git.clone() };
217 let scratch: String = format!("refs/worktree/cli-git-service/{seq}");
218 let mut outcomes: Vec<Outcome>;
219 let mut attempts_left: usize = LANDING_ATTEMPTS;
220 let unborn: bool = is_zero_object(first.base.as_str());
222 let waiting_since: Instant = Instant::now();
226 let taken = acquire_noting_holder(first.index.as_path(), seq, context.index_wait, announce, &mut timing.lock_holder);
227 timing.lock_wait = waiting_since.elapsed();
228 let mut lock: Option<IndexLock> = Some(taken.map_err(|failure| { return failure.code; })?);
229 loop {
230 timing.attempts += 1;
231 let old: String = match checked(&git, repository, &["rev-parse", "--verify", "--quiet", first.reference.as_str()], None) {
232 Ok(found) => found,
233 Err(_) if unborn => first.base.clone(),
234 Err(code) => return Err(code),
235 };
236 if alone && first.base != old {
237 return Ok(vec![Outcome::BranchMoved]);
238 }
239 if first.reference == "HEAD" && run(&git, repository, &["symbolic-ref", "--quiet", "HEAD"], None)?.succeeded() {
242 return Ok(vec![Outcome::BranchMoved]);
243 }
244 if first.reference != "HEAD" && !head_names(&git, repository, first.index.as_path(), first.common_dir.as_path(), first.reference.as_str())? {
247 return Ok(vec![Outcome::BranchMoved; requests.len()]);
248 }
249 let amended_parents: Vec<String> = if alone && !unborn {
250 let listing: String = checked(&git, repository, &["rev-list", "--parents", "--max-count=1", first.base.as_str()], None)?;
251 listing.split_whitespace().skip(1).map(str::to_string).collect()
252 } else {
253 Vec::new()
254 };
255 let mut changed_since: HashMap<&str, BTreeSet<String>> = HashMap::new();
257 let mut landing: Vec<(usize, &CommitRequest)> = Vec::new();
258 outcomes = vec![Outcome::RetryPrepare { base: old.clone() }; requests.len()];
259 for (index, request) in requests.iter().enumerate() {
260 if request.base != old && !changed_since.contains_key(request.base.as_str()) {
261 let listing: String = checked(&git, repository, &["diff-tree", "-r", "--name-only", "-z", request.base.as_str(), old.as_str()], None)?;
262 changed_since.insert(request.base.as_str(), listing.split('\0').filter(|name| { return !name.is_empty(); }).map(str::to_string).collect());
263 }
264 let conflicts: bool = request.base != old
265 && request.changes.iter().any(|change| {
266 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()); }); });
267 });
268 let shadowed: bool = landing.iter().any(|(_, earlier)| {
272 let later_capture: bool = earlier.captured != 0 && request.captured > earlier.captured;
273 return !later_capture && request.changes.iter().any(|change| { return earlier.changes.iter().any(|member| { return overlaps(change.path.as_str(), member.path.as_str()); }); });
274 });
275 if !conflicts && !shadowed {
276 landing.push((index, request));
277 }
278 }
279 if landing.is_empty() {
280 return Ok(outcomes);
281 }
282 let batch: Vec<&CommitRequest> = landing.iter().map(|(_, request)| { return *request; }).collect();
283 if batch_content_missing(first.common_dir.as_path(), batch.as_slice()) {
286 return Err(FailureCode::Unavailable);
287 }
288 let objects_began: Instant = Instant::now();
289 let (commits, new): (Vec<String>, String) = if let Some(built) = first.commit.as_deref() {
290 let listing: GitOutput = run(&git, repository, &["rev-list", "--parents", "--max-count=1", built], None)?;
294 let text: String = String::from_utf8_lossy(listing.stdout.as_slice()).into_owned();
295 let mut words = text.split_whitespace();
296 let named_itself: bool = listing.succeeded() && words.next() == Some(built);
297 let parents: Vec<String> = words.map(str::to_string).collect();
298 let acceptable: bool = if first.amend {
299 parents == amended_parents
300 } else if unborn {
301 parents.is_empty()
302 } else {
303 parents.first() == Some(&first.base)
304 };
305 if !named_itself || !acceptable {
306 return Err(FailureCode::Rejected);
307 }
308 let connected: Vec<&str> = if unborn { vec!["rev-list", "--objects", "--quiet", built] } else { vec!["rev-list", "--objects", "--quiet", built, "--not", old.as_str()] };
311 if !run(&git, repository, connected.as_slice(), None)?.succeeded() {
312 return Err(FailureCode::Unavailable);
313 }
314 (vec![built.to_string()], built.to_string())
315 } else {
316 let marks: PathBuf = context.journal.join(format!("marks-{seq}"));
317 let marks_argument: String = format!("--export-marks={}", marks.display());
318 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())))?;
319 let marks_text: String = std::fs::read_to_string(&marks).map_err(|_| { return FailureCode::Unavailable; })?;
320 let _marks_removed = std::fs::remove_file(&marks);
321 let made: Vec<String> = commits_from_marks(marks_text.as_str(), batch.len()).ok_or(FailureCode::Unavailable)?;
322 let last: String = made.last().cloned().ok_or(FailureCode::Unavailable)?;
323 (made, last)
324 };
325 timing.objects += objects_began.elapsed();
326 reach("after-objects");
327 if let Some(seam) = &context.on_objects_written {
328 seam();
329 }
330 let derived: bool = first.commit.is_some();
332 let paths: Vec<String> = if derived {
333 Vec::new()
334 } else {
335 batch.iter().flat_map(|request| { return request.changes.iter().map(|change| { return change.path.clone(); }); }).collect()
336 };
337 let intent: Intent = Intent {
338 seq,
339 repository: first.repository.clone(),
340 common_dir: first.common_dir.clone(),
341 reference: first.reference.clone(),
342 old: old.clone(),
343 new: new.clone(),
344 ids: batch.iter().map(|request| { return request.id.clone(); }).collect(),
345 commits: commits.clone(),
346 paths,
347 derived,
348 cleanup: first.cleanup.clone(),
349 index: first.index.clone(),
350 git_dir: first.git_dir.clone(),
351 };
352 let journal_began: Instant = Instant::now();
353 let written = write_intent(context.journal.as_path(), &intent);
354 timing.journal += journal_began.elapsed();
355 written.map_err(|error| { return storage_failure(&error); })?;
356 reach("after-intent");
357 let removal: String = if first.commit.is_some() { String::new() } else { format!("delete {scratch}\n") };
359 let direct: &str = if first.reference == "HEAD" { "option no-deref\n" } else { "" };
362 let transaction: String = format!("start\n{direct}update {} {new} {old}\n{removal}prepare\ncommit\n", first.reference);
363 let ref_began: Instant = Instant::now();
364 let ran = run(&git, repository, &["update-ref", "--stdin"], Some(transaction.into_bytes()));
365 timing.ref_update += ref_began.elapsed();
366 let moved: GitOutput = ran?;
367 if !moved.succeeded() {
368 let _intent_removed = std::fs::remove_file(super::service_journal::batch_path(context.journal.as_path(), seq, OPEN_EXTENSION));
370 let _deleted = run(&git, repository, &["update-ref", "-d", scratch.as_str()], None);
371 attempts_left -= 1;
373 if attempts_left == 0 {
374 return Err(FailureCode::BranchKeepsChanging);
375 }
376 continue;
377 }
378 reach("after-ref");
379 let Some(held) = lock.take() else { return Err(FailureCode::Unavailable) };
381 let sync_began: Instant = Instant::now();
382 let synced: bool = follow_up(&git, repository, &intent, held);
383 timing.sync += sync_began.elapsed();
384 if synced {
385 reach("after-index");
386 let _marked = mark_done(context.journal.as_path(), seq);
387 }
388 for (slot, (index, request)) in landing.iter().enumerate() {
389 outcomes[*index] = Outcome::Landed { commit: commits[slot].clone() };
390 super::service_captures::record_landed(request.common_dir.as_path(), request.repository.as_path(), commits[slot].as_str(), request.captured);
391 }
392 return Ok(outcomes);
393 }
394}
395
396#[derive(Clone, Debug, Eq, PartialEq)]
398pub struct RecoveryReport {
399 pub seq: u64,
401 pub decision: Recovery,
403}
404
405pub fn recover(context: &LandContext) -> Vec<RecoveryReport> {
416 return recover_selected(context, &|_| { return true; }, true);
417}
418
419pub fn recover_instance(context: &LandContext, instance: &str) -> Vec<RecoveryReport> {
434 let ours = |intent: &Intent| -> bool { return instance_of(intent.git_dir.as_path()) == instance; };
435 return recover_selected(context, &ours, false);
436}
437
438fn recover_selected(context: &LandContext, selected: &dyn Fn(&Intent) -> bool, remove_unreadable: bool) -> Vec<RecoveryReport> {
439 let mut reports: Vec<RecoveryReport> = Vec::new();
440 for (path, parsed) in list_intents(context.journal.as_path(), OPEN_EXTENSION) {
441 let Some(intent) = parsed else {
442 if remove_unreadable {
443 let _removed = std::fs::remove_file(&path);
444 }
445 continue;
446 };
447 if !selected(&intent) {
448 continue;
449 }
450 let git: GitContext = GitContext { common_dir: Some(intent.common_dir.clone()), ..context.git.clone() };
451 let current: String = checked(&git, intent.repository.as_path(), &["rev-parse", "--verify", intent.reference.as_str()], None).unwrap_or_default();
452 let ancestor: bool = run(&git, intent.repository.as_path(), &["merge-base", "--is-ancestor", intent.new.as_str(), current.as_str()], None)
453 .is_ok_and(|output| { return output.succeeded(); });
454 let decision: Recovery = recovery_for(&intent, current.as_str(), ancestor);
455 match decision {
456 Recovery::Drop => {
457 remove_stale(intent.index.as_path(), intent.seq);
458 let _removed = std::fs::remove_file(&path);
459 }
460 Recovery::Finish => {
461 remove_stale(intent.index.as_path(), intent.seq);
462 if let Some(held) = try_acquire(intent.index.as_path(), intent.seq)
463 && follow_up(&git, intent.repository.as_path(), &intent, held)
464 {
465 let _marked = mark_done(context.journal.as_path(), intent.seq);
466 }
467 }
468 }
469 reports.push(RecoveryReport { seq: intent.seq, decision });
470 }
471 return reports;
472}
473
474#[cfg(test)]
476#[path = "service_land_tests.rs"]
477mod tests;