1mod git;
4mod render_source;
5mod tree_validation;
6mod types;
7
8use ito_config::types::{ItoConfig, ProposalIntegrationMode};
9
10pub use render_source::{AuthoritativeChangeSource, AuthoritativeSourceError};
11pub use types::{
12 AuthorityEvidence, AuthoritySnapshot, ReadinessCondition, ReadinessPhase, ReadinessReport,
13 ReadinessRequest,
14};
15
16use self::git::{CheckoutState, ReadinessGit, SystemReadinessGit};
17use self::tree_validation::{PrepareFailureKind, validate_authoritative_change};
18
19pub fn evaluate_readiness(request: &ReadinessRequest, config: &ItoConfig) -> ReadinessReport {
21 evaluate_readiness_with_git(request, config, &SystemReadinessGit)
22}
23
24pub fn materialize_authoritative_change(
27 prepare: &ReadinessReport,
28 repository_root: &std::path::Path,
29 guidance_artifacts: &[&str],
30) -> Result<AuthoritativeChangeSource, AuthoritativeSourceError> {
31 render_source::materialize_authoritative_change(
32 prepare,
33 repository_root,
34 guidance_artifacts,
35 &SystemReadinessGit,
36 )
37}
38
39#[must_use]
41pub fn render_readiness_text(report: &ReadinessReport) -> String {
42 let status = if report.ready { "ready" } else { "not ready" };
43 let mut lines = vec![
44 format!(
45 "Change '{}' is {status} for {:?}.",
46 report.change_id, report.phase
47 ),
48 format!("Integration mode: {}", report.authority.integration_mode),
49 format!(
50 "Authority ref: {}",
51 report
52 .authority
53 .target_ref
54 .as_deref()
55 .unwrap_or("unresolved")
56 ),
57 format!(
58 "Authority OID: {}",
59 report.authority.oid.as_deref().unwrap_or("unresolved")
60 ),
61 format!(
62 "Proposal integration OID: {}",
63 report
64 .proposal_integration_oid
65 .as_deref()
66 .unwrap_or("unresolved")
67 ),
68 String::new(),
69 "Conditions:".to_string(),
70 ];
71 for condition in &report.conditions {
72 let mark = if condition.passed { "PASS" } else { "FAIL" };
73 lines.push(format!(
74 " [{mark}] {}: {}",
75 condition.code, condition.message
76 ));
77 if let Some(remediation) = &condition.remediation {
78 lines.push(format!(" Fix: {remediation}"));
79 }
80 }
81 lines.join("\n")
82}
83
84pub fn evaluate_execute_from_prepare(
90 prepare: &ReadinessReport,
91 request: &ReadinessRequest,
92 config: &ItoConfig,
93) -> ReadinessReport {
94 evaluate_execute_from_prepare_with_git(prepare, request, config, &SystemReadinessGit)
95}
96
97fn evaluate_execute_from_prepare_with_git(
98 prepare: &ReadinessReport,
99 request: &ReadinessRequest,
100 config: &ItoConfig,
101 git: &dyn ReadinessGit,
102) -> ReadinessReport {
103 let mut report = prepare.clone();
104 report.phase = ReadinessPhase::Execute;
105 report.ready = false;
106 let valid_prepare = prepare.phase == ReadinessPhase::Prepare
107 && prepare.ready
108 && prepare.change_id == request.change_id
109 && request.phase == ReadinessPhase::Execute
110 && prepare.authority.integration_mode == config.changes.proposal.integration_mode;
111 let Some(integration_oid) = prepare.proposal_integration_oid.as_deref() else {
112 report.conditions.push(ReadinessCondition::failed(
113 "prepare_snapshot",
114 "The prepare report does not contain a proposal integration commit.",
115 "Run prepare readiness successfully immediately before creating the implementation worktree.",
116 ));
117 return report;
118 };
119 if !valid_prepare || prepare.authority_snapshot().is_none() {
120 report.conditions.push(ReadinessCondition::failed(
121 "prepare_snapshot",
122 "The supplied prepare report is not a successful compatible authority snapshot for this execute request.",
123 "Run prepare readiness for this exact change and integration mode, then retry without substituting another report.",
124 ));
125 return report;
126 }
127
128 evaluate_execute_conditions(&mut report, request, config, git, integration_oid);
129 report
130}
131
132pub(crate) fn evaluate_readiness_with_git(
133 request: &ReadinessRequest,
134 config: &ItoConfig,
135 git: &dyn ReadinessGit,
136) -> ReadinessReport {
137 let mut report = evaluate_authority_with_git(request, config, git);
138 if !report.ready {
139 return report;
140 }
141 report.ready = false;
142
143 let snapshot = report
144 .authority_snapshot()
145 .expect("successful authority resolution returns a complete snapshot");
146 let canonical_change_id = match resolve_authoritative_change_target(git, request, &snapshot) {
147 Ok(change_id) => change_id,
148 Err((message, remediation)) => {
149 report.conditions.push(ReadinessCondition::failed(
150 "change_target",
151 message,
152 remediation,
153 ));
154 return report;
155 }
156 };
157 report.change_id = canonical_change_id.clone();
158 report.conditions.push(ReadinessCondition::passed(
159 "change_target",
160 format!(
161 "Resolved change target '{}' to authoritative change '{}'.",
162 request.change_id, canonical_change_id
163 ),
164 ));
165 let mut request = request.clone();
166 request.change_id = canonical_change_id;
167 let proof = match validate_authoritative_change(git, &request, &snapshot) {
168 Ok(proof) => proof,
169 Err(failure) => {
170 let remediation = format!(
171 "Correct '{}' in the reviewed proposal, integrate the fix through {}, and retry readiness.",
172 failure.path, snapshot.integration_mode
173 );
174 let condition = match failure.kind {
175 PrepareFailureKind::Artifacts => ReadinessCondition::failed_artifact(
176 format!("{}: {}", failure.path, failure.message),
177 remediation,
178 failure.path,
179 ),
180 PrepareFailureKind::Validation => ReadinessCondition::failed_validation(
181 failure.message,
182 remediation,
183 failure.path,
184 failure.validator_code,
185 ),
186 };
187 report.conditions.push(condition);
188 return report;
189 }
190 };
191 report.conditions.push(ReadinessCondition::passed(
192 "authoritative_artifacts",
193 format!(
194 "Loaded {} apply prerequisite file(s) for schema '{}' from authority commit '{}'.",
195 proof.artifact_paths.len(),
196 proof.schema_name,
197 snapshot.oid
198 ),
199 ));
200 report.conditions.push(ReadinessCondition::passed(
201 "authoritative_validation",
202 format!(
203 "Authoritative proposal files passed strict '{}' schema validation.",
204 proof.schema_name
205 ),
206 ));
207
208 let marker_path = format!(".ito/changes/{}/.ito.yaml", request.change_id);
209 let integration_oid = match git.find_introduction_commit(
210 &request.repository_root,
211 &snapshot.oid,
212 &marker_path,
213 ) {
214 Ok(oid) => {
215 report.proposal_integration_oid = Some(oid.clone());
216 report.conditions.push(ReadinessCondition::passed(
217 "proposal_integration",
218 format!(
219 "Target first-parent history introduced the proposal marker at commit '{oid}'."
220 ),
221 ));
222 oid
223 }
224 Err(error) => {
225 report.conditions.push(ReadinessCondition::failed(
226 "proposal_integration",
227 format!("Cannot prove proposal integration in target history: {error}"),
228 format!(
229 "Integrate the reviewed proposal into '{}' with complete history available, then retry.",
230 snapshot.target_ref
231 ),
232 ));
233 return report;
234 }
235 };
236
237 if request.phase == ReadinessPhase::Prepare {
238 report.ready = true;
239 return report;
240 }
241
242 evaluate_execute_conditions(&mut report, &request, config, git, &integration_oid);
243 report
244}
245
246fn resolve_authoritative_change_target(
247 git: &dyn ReadinessGit,
248 request: &ReadinessRequest,
249 snapshot: &AuthoritySnapshot,
250) -> Result<String, (String, String)> {
251 let entries = git
252 .list_tree(&request.repository_root, &snapshot.oid, ".ito/changes")
253 .map_err(|error| {
254 (
255 format!("Cannot list authoritative changes: {error}"),
256 "Make the accepted proposal tree available locally and retry.".to_string(),
257 )
258 })?;
259 let mut change_ids = entries
260 .iter()
261 .filter(|entry| entry.path.ends_with("/.ito.yaml"))
262 .filter_map(|entry| {
263 entry
264 .path
265 .strip_prefix(".ito/changes/")
266 .and_then(|path| path.strip_suffix("/.ito.yaml"))
267 })
268 .filter(|change_id| !change_id.is_empty() && !change_id.contains('/'))
269 .map(ToOwned::to_owned)
270 .collect::<Vec<_>>();
271 change_ids.sort();
272 change_ids.dedup();
273
274 let input = request.change_id.trim();
275 if let Some(exact) = change_ids
276 .iter()
277 .find(|change_id| change_id.as_str() == input)
278 {
279 return Ok(exact.clone());
280 }
281 let matches = change_ids
282 .into_iter()
283 .filter(|change_id| change_id.starts_with(input))
284 .collect::<Vec<_>>();
285 if matches.len() == 1 {
286 return Ok(matches[0].clone());
287 }
288 if matches.is_empty() {
289 return Err((
290 format!(
291 "Change target '{}' was not found in authority commit '{}'.",
292 request.change_id, snapshot.oid
293 ),
294 "Integrate the reviewed proposal into the authoritative target branch, or use its canonical change ID."
295 .to_string(),
296 ));
297 }
298 Err((
299 format!(
300 "Change target '{}' is ambiguous in authority commit '{}'. Matches: {}.",
301 request.change_id,
302 snapshot.oid,
303 matches.join(", ")
304 ),
305 "Use a longer prefix or the full canonical change ID.".to_string(),
306 ))
307}
308
309fn evaluate_execute_conditions(
310 report: &mut ReadinessReport,
311 request: &ReadinessRequest,
312 config: &ItoConfig,
313 git: &dyn ReadinessGit,
314 integration_oid: &str,
315) {
316 let Some(checkout_path) = request.current_checkout.as_deref() else {
317 report.conditions.push(ReadinessCondition::failed(
318 "checkout_identity",
319 "Execute readiness requires a current implementation checkout.",
320 "Run execute preflight from the dedicated change worktree, or provide that checkout explicitly.",
321 ));
322 return;
323 };
324 let repository_state = match git.inspect_checkout(&request.repository_root) {
325 Ok(state) => state,
326 Err(error) => {
327 report.conditions.push(ReadinessCondition::failed(
328 "checkout_identity",
329 format!("Cannot inspect the authority repository identity: {error}"),
330 "Run readiness from a valid Git repository and retry.",
331 ));
332 return;
333 }
334 };
335 let checkout_state = match git.inspect_checkout(checkout_path) {
336 Ok(state) => state,
337 Err(error) => {
338 report.conditions.push(ReadinessCondition::failed(
339 "checkout_identity",
340 format!(
341 "Cannot inspect implementation checkout '{}': {error}",
342 checkout_path.display()
343 ),
344 "Select a valid dedicated Git worktree for this change and retry.",
345 ));
346 return;
347 }
348 };
349
350 let same_repository = repository_state.common_dir == checkout_state.common_dir;
351 let ancestry_passed = if same_repository {
352 match git.is_ancestor(
353 &checkout_state.root,
354 integration_oid,
355 &checkout_state.head_oid,
356 ) {
357 Ok(true) => {
358 report.conditions.push(ReadinessCondition::passed(
359 "implementation_ancestry",
360 format!(
361 "Implementation HEAD '{}' descends from proposal integration commit '{}'.",
362 checkout_state.head_oid, integration_oid
363 ),
364 ));
365 true
366 }
367 Ok(false) => {
368 report.conditions.push(ReadinessCondition::failed(
369 "implementation_ancestry",
370 format!(
371 "Implementation HEAD '{}' does not contain proposal integration commit '{}'.",
372 checkout_state.head_oid, integration_oid
373 ),
374 "Recreate the change worktree from the verified authority commit, or rebase/merge the accepted proposal history before implementation.",
375 ));
376 false
377 }
378 Err(error) => {
379 report.conditions.push(ReadinessCondition::failed(
380 "implementation_ancestry",
381 format!("Cannot inspect implementation ancestry: {error}"),
382 "Make the complete proposal and implementation history available locally, then retry.",
383 ));
384 false
385 }
386 }
387 } else {
388 report.conditions.push(ReadinessCondition::failed(
389 "implementation_ancestry",
390 "The selected checkout belongs to a different Git repository, so proposal ancestry cannot be proven.",
391 "Select a dedicated change worktree from the authoritative repository and retry.",
392 ));
393 false
394 };
395
396 let identity_passed =
397 evaluate_checkout_identity(report, request, config, &checkout_state, same_repository);
398 report.ready = ancestry_passed && identity_passed;
399}
400
401fn evaluate_checkout_identity(
402 report: &mut ReadinessReport,
403 request: &ReadinessRequest,
404 config: &ItoConfig,
405 checkout: &CheckoutState,
406 same_repository: bool,
407) -> bool {
408 let branch = checkout.branch.as_deref();
409 let failure = if !same_repository {
410 Some("The selected checkout is not a worktree of the authoritative repository.".to_string())
411 } else if checkout.is_bare {
412 Some(
413 "The selected checkout is a bare control repository, not an implementation worktree."
414 .to_string(),
415 )
416 } else if branch == Some(config.worktrees.default_branch.as_str()) {
417 Some(format!(
418 "The selected checkout is the authoritative target branch '{}', not a dedicated implementation worktree.",
419 config.worktrees.default_branch
420 ))
421 } else if !crate::worktree_validate::checkout_matches_change_id(
422 &checkout.root,
423 branch,
424 &request.change_id,
425 ) {
426 Some(format!(
427 "Checkout '{}'{} is not associated with full change ID '{}'.",
428 checkout.root.display(),
429 branch
430 .map(|name| format!(" on branch '{name}'"))
431 .unwrap_or_default(),
432 request.change_id
433 ))
434 } else {
435 None
436 };
437
438 if let Some(message) = failure {
439 report.conditions.push(ReadinessCondition::failed(
440 "checkout_identity",
441 message,
442 format!(
443 "Use the dedicated '{}' worktree (a suffixed review worktree is also accepted) and retry.",
444 request.change_id
445 ),
446 ));
447 return false;
448 }
449
450 report.conditions.push(ReadinessCondition::passed(
451 "checkout_identity",
452 format!(
453 "Checkout '{}'{} is associated with change '{}'.",
454 checkout.root.display(),
455 branch
456 .map(|name| format!(" on branch '{name}'"))
457 .unwrap_or_default(),
458 request.change_id
459 ),
460 ));
461 true
462}
463
464#[cfg(test)]
470pub(crate) fn evaluate_authority(
471 request: &ReadinessRequest,
472 config: &ItoConfig,
473) -> ReadinessReport {
474 evaluate_authority_with_git(request, config, &SystemReadinessGit)
475}
476
477pub(crate) fn evaluate_authority_with_git(
482 request: &ReadinessRequest,
483 config: &ItoConfig,
484 git: &dyn ReadinessGit,
485) -> ReadinessReport {
486 let mode = config.changes.proposal.integration_mode;
487 let local_target_ref = format!("refs/heads/{}", config.worktrees.default_branch);
488 let mut report = ReadinessReport::new(request, mode);
489
490 let upstream = match mode {
491 ProposalIntegrationMode::DirectMerge => {
492 report.authority.target_ref = Some(local_target_ref.clone());
493 report.conditions.push(ReadinessCondition::passed(
494 "authority_ref",
495 format!("Using local target ref '{local_target_ref}' for direct-merge authority."),
496 ));
497 None
498 }
499 ProposalIntegrationMode::PullRequest => {
500 match git.tracked_upstream(&request.repository_root, &local_target_ref) {
501 Ok(upstream) => {
502 report.authority.target_ref = Some(upstream.tracking_ref.clone());
503 report.conditions.push(ReadinessCondition::passed(
504 "authority_ref",
505 format!(
506 "Using tracked upstream ref '{}' for pull-request authority.",
507 upstream.tracking_ref
508 ),
509 ));
510 Some(upstream)
511 }
512 Err(error) => {
513 report.conditions.push(ReadinessCondition::failed(
514 "authority_ref",
515 format!(
516 "Cannot resolve the tracked upstream for target ref '{local_target_ref}': {error}"
517 ),
518 "Configure and fetch the target branch's tracked upstream, or explicitly set changes.proposal.integration_mode to direct_merge if this repository does not use a pull_request workflow.",
519 ));
520 return report;
521 }
522 }
523 }
524 };
525
526 if request.refresh_authority {
527 if let Some(upstream) = upstream.as_ref() {
528 match git.refresh_upstream(&request.repository_root, upstream) {
529 Ok(()) => report.conditions.push(ReadinessCondition::passed(
530 "authority_refresh",
531 format!("Refreshed authority ref '{}'.", upstream.tracking_ref),
532 )),
533 Err(error) => {
534 report.conditions.push(ReadinessCondition::failed(
535 "authority_refresh",
536 format!(
537 "Cannot refresh authority ref '{}': {error}",
538 upstream.tracking_ref
539 ),
540 format!(
541 "Check access to remote '{}' and retry the refreshed preflight.",
542 upstream.remote
543 ),
544 ));
545 return report;
546 }
547 }
548 } else {
549 report.conditions.push(ReadinessCondition::passed(
550 "authority_refresh",
551 "Direct-merge authority is local and does not require a remote refresh.",
552 ));
553 }
554 }
555
556 let target_ref = report
557 .authority
558 .target_ref
559 .as_deref()
560 .expect("successful authority selection always records a target ref");
561 match git.resolve_commit(&request.repository_root, target_ref) {
562 Ok(oid) => {
563 report.authority.oid = Some(oid.clone());
564 report.conditions.push(ReadinessCondition::passed(
565 "authority_oid",
566 format!("Resolved authority ref '{target_ref}' to commit '{oid}'."),
567 ));
568 report.ready = true;
569 }
570 Err(error) => report.conditions.push(ReadinessCondition::failed(
571 "authority_oid",
572 format!("Cannot resolve authority ref '{target_ref}' to a commit: {error}"),
573 match mode {
574 ProposalIntegrationMode::PullRequest => {
575 format!("Fetch the configured upstream target ref '{target_ref}' and retry.")
576 }
577 ProposalIntegrationMode::DirectMerge => format!(
578 "Create or update the local target branch '{}' and retry.",
579 config.worktrees.default_branch
580 ),
581 },
582 )),
583 }
584
585 report
586}
587
588#[cfg(test)]
589#[path = "implementation_readiness_tests.rs"]
590mod tests;