diff options
Diffstat (limited to 'mingling_ci/src/cmd')
| -rw-r--r-- | mingling_ci/src/cmd/cmd_git_lock.rs | 77 | ||||
| -rw-r--r-- | mingling_ci/src/cmd/cmd_git_unlock.rs | 116 | ||||
| -rw-r--r-- | mingling_ci/src/cmd/cmd_report_clean.rs | 54 | ||||
| -rw-r--r-- | mingling_ci/src/cmd/cmd_report_collect.rs | 150 | ||||
| -rw-r--r-- | mingling_ci/src/cmd/cmd_show_features.rs | 26 | ||||
| -rw-r--r-- | mingling_ci/src/cmd/cmd_show_manifests.rs | 71 |
6 files changed, 494 insertions, 0 deletions
diff --git a/mingling_ci/src/cmd/cmd_git_lock.rs b/mingling_ci/src/cmd/cmd_git_lock.rs new file mode 100644 index 0000000..0e9bf22 --- /dev/null +++ b/mingling_ci/src/cmd/cmd_git_lock.rs @@ -0,0 +1,77 @@ +use mingling::{ + Grouped, RenderResult, Routable, + macros::{buffer, command, r_println, renderer}, + res::ResExitCode, +}; + +use crate::Next; +use crate::git::{CI_TEMP_COMMIT_MESSAGE, LOCK_FILE, TEMP_COMMIT_MESSAGE, run_git, worktree_clean}; +use crate::res::{CargoError, MessagePrinter}; + +/// Temporarily commits the workspace so CI can run on a stable tree. +/// +/// First pins the current HEAD to the `mingling/bkup` backup branch (created +/// or force-reset). When the tree is dirty, all changes are packed into a +/// plain `TEMP` commit first so they can be restored later; the `CI TEMP` +/// commit then carries only the `MINGLING-CI-CHECKING` marker file, whose +/// content (`true`/`false`) tells `git-unlock` which restore path to take. +#[command(node = "git-lock")] +pub fn git_lock() -> Next { + if let Err(e) = run_git(["branch", "-f", "mingling/bkup", "HEAD"]) { + return ErrorGitLock(e).to_chain(); + } + + let dirty = !worktree_clean(); + if dirty { + if let Err(e) = run_git(["add", "."]) { + return ErrorGitLock(e).to_chain(); + } + if let Err(e) = run_git(["commit", "-m", TEMP_COMMIT_MESSAGE]) { + return ErrorGitLock(e).to_chain(); + } + } + + let marker = if dirty { "true" } else { "false" }; + if let Err(e) = std::fs::write(LOCK_FILE, marker) { + return ErrorGitLock(format!("failed to create {LOCK_FILE}: {e}")).to_chain(); + } + + if let Err(e) = run_git(["add", "."]) { + return ErrorGitLock(e).to_chain(); + } + if let Err(e) = run_git(["commit", "-m", CI_TEMP_COMMIT_MESSAGE]) { + return ErrorGitLock(e).to_chain(); + } + + ResultGitLock { dirty }.to_chain() +} + +/// Whether the tree was dirty (a base `TEMP` commit exists) when locking. +#[derive(Grouped)] +pub struct ResultGitLock { + dirty: bool, +} + +#[derive(Grouped, Default)] +pub struct ErrorGitLock(pub String); + +#[renderer(buffer)] +pub fn render_git_lock(r: ResultGitLock) { + if r.dirty { + r_println!("Locked: dirty workspace committed for CI"); + } else { + r_println!("Locked: clean workspace marked for CI"); + } +} + +#[renderer] +pub fn render_error_git_lock( + e: ErrorGitLock, + error: &CargoError, + exit_code: &mut ResExitCode, +) -> RenderResult { + let render_result = RenderResult::new(); + error.println(vec![format!("Git-Lock: {}", e.0)]); + exit_code.exit_code = 1; + render_result +} diff --git a/mingling_ci/src/cmd/cmd_git_unlock.rs b/mingling_ci/src/cmd/cmd_git_unlock.rs new file mode 100644 index 0000000..41efefc --- /dev/null +++ b/mingling_ci/src/cmd/cmd_git_unlock.rs @@ -0,0 +1,116 @@ +use mingling::{ + Grouped, RenderResult, Routable, + macros::{arg, buffer, command, r_println, renderer}, + picker::{EntryPicker, value::Flag}, + res::ResExitCode, +}; + +use crate::git::{LOCK_FILE, TEMP_COMMIT_MARK, head_message, run_git, worktree_clean}; +use crate::res::{CargoError, MessagePrinter}; +use crate::{Entry, Next}; + +/// Undoes a CI temporary commit created by [`crate::cmd::cmd_git_lock`]. +/// +/// Only acts when the HEAD commit message contains `CI TEMP` (case-sensitive). +/// The restore path is picked by the marker file content: +/// +/// - `true`: a base `TEMP` commit with the dirty changes sits below; restore +/// by hard-resetting past the marker commit, then soft-resetting and +/// unstaging to put the user's changes back into the working tree. +/// - `false`: the tree was clean; a single hard reset back to the original +/// HEAD is enough. +/// +/// When the working tree is dirty (e.g. CI left tracked changes behind) the +/// restore still runs, but the command reports a non-zero exit code so the +/// caller knows the CI phase contaminated the repository. With `--show-diff` +/// the diff of those changes is printed before they are discarded. +#[command(node = "git-unlock")] +// `#[command]` rewrites an owned first param into the entry type, so the args +// must be passed by value even though the body only reads them. +#[allow(clippy::needless_pass_by_value)] +pub fn git_unlock(args: Entry) -> Next { + let head = head_message().unwrap_or_default(); + if !head.contains(TEMP_COMMIT_MARK) { + return ErrorGitUnlock(format!("HEAD is not a CI temporary commit: `{head}`")).to_chain(); + } + + // Record dirtiness before restoring: the restore discards those changes. + let dirty = !worktree_clean(); + + // The marker file lives in the HEAD (CI TEMP) commit, so it is readable + // from the working tree; a missing marker falls back to the clean path. + let based_on_dirty = + std::fs::read_to_string(LOCK_FILE).is_ok_and(|content| content.trim() == "true"); + + if dirty && *args.pick(&arg![show_diff: Flag]).unwrap() { + show_diff(); + } + + if let Err(e) = undo_ci_phase(based_on_dirty) { + return ErrorGitUnlock(e).to_chain(); + } + + ResultGitUnlock { dirty }.to_chain() +} + +/// Prints the tracked changes the CI run left behind, before the restore +/// discards them. Untracked files are not shown (they are removed by clean). +fn show_diff() { + let Ok(diff) = run_git(["diff", "HEAD"]) else { + return; + }; + if diff.is_empty() { + return; + } + println!("{diff}"); +} + +/// Restores the workspace, keeping the user's pre-lock changes. +/// +/// With a base `TEMP` commit (`true`) the marker commit is dropped by a hard +/// reset to `HEAD~1`, the `TEMP` commit is unwrapped into the staging area by +/// a soft reset, and a plain reset unstages it back into the working tree. +/// Without one (`false`) a single hard reset to `HEAD~1` removes the marker +/// commit and lands on the original HEAD. +fn undo_ci_phase(based_on_dirty: bool) -> Result<(), String> { + run_git(["reset", "--hard", "HEAD~1"])?; + if based_on_dirty { + // Unwrap the `TEMP` commit into the staging area, then unstage it + // back into the working tree. + run_git(["reset", "--soft", "HEAD~1"])?; + run_git(["reset"])?; + } + std::fs::remove_file(LOCK_FILE).ok(); + Ok(()) +} + +/// Whether the working tree was dirty when the unlock started. +#[derive(Grouped)] +pub struct ResultGitUnlock { + dirty: bool, +} + +#[derive(Grouped, Default)] +pub struct ErrorGitUnlock(pub String); + +#[renderer(buffer)] +pub fn render_git_unlock(r: ResultGitUnlock, exit_code: &mut ResExitCode) { + if r.dirty { + r_println!("Unlocked: workspace restored (working tree was dirty)"); + exit_code.exit_code = 1; + } else { + r_println!("Unlocked: workspace restored"); + } +} + +#[renderer] +pub fn render_error_git_unlock( + e: ErrorGitUnlock, + error: &CargoError, + exit_code: &mut ResExitCode, +) -> RenderResult { + let render_result = RenderResult::new(); + error.println(vec![format!("Git-Unlock: {}", e.0)]); + exit_code.exit_code = 1; + render_result +} diff --git a/mingling_ci/src/cmd/cmd_report_clean.rs b/mingling_ci/src/cmd/cmd_report_clean.rs new file mode 100644 index 0000000..976851e --- /dev/null +++ b/mingling_ci/src/cmd/cmd_report_clean.rs @@ -0,0 +1,54 @@ +use std::path::PathBuf; + +use mingling::{ + Grouped, RenderResult, Routable, + macros::{buffer, command, r_println, renderer}, +}; + +use crate::Next; +use crate::reporter::{COLLECT_DIR, REPORT_PATH}; +use crate::res::{CargoError, MessagePrinter}; + +/// Removes collected logs and the generated report. +#[command(node = "report-clean")] +pub fn report_clean() -> Next { + let mut removed = Vec::new(); + for path in [PathBuf::from(COLLECT_DIR), PathBuf::from(REPORT_PATH)] { + match std::fs::remove_dir_all(&path).or_else(|_| std::fs::remove_file(&path)) { + Ok(()) => removed.push(path), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return ErrorReportClean(format!("failed to remove {}: {e}", path.display())) + .to_chain(); + } + } + } + ResultReportClean { removed }.to_chain() +} + +/// Paths removed by `report-clean`. +#[derive(Grouped)] +pub struct ResultReportClean { + pub removed: Vec<PathBuf>, +} + +#[derive(Grouped, Default)] +pub struct ErrorReportClean(pub String); + +#[renderer(buffer)] +pub fn render_report_clean(r: ResultReportClean) { + if r.removed.is_empty() { + r_println!("Report data already clean"); + } else { + for path in r.removed { + r_println!("Removed {}", path.display()); + } + } +} + +#[renderer] +pub fn render_error_report_clean(e: ErrorReportClean, error: &CargoError) -> RenderResult { + let render_result = RenderResult::new(); + error.println(vec![format!("Report: {}", e.0)]); + render_result +} diff --git a/mingling_ci/src/cmd/cmd_report_collect.rs b/mingling_ci/src/cmd/cmd_report_collect.rs new file mode 100644 index 0000000..2eff074 --- /dev/null +++ b/mingling_ci/src/cmd/cmd_report_collect.rs @@ -0,0 +1,150 @@ +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; + +use just_template::Template; +use mingling::{ + Grouped, RenderResult, Routable, + macros::{buffer, command, r_println, renderer}, +}; + +use crate::Next; +use crate::reporter::{COLLECT_DIR, REPORT_PATH}; +use crate::res::{CargoError, MessagePrinter, ResCollectLogs}; + +const REPORT_TEMPLATE: &str = include_str!("../../tmpls/report.md"); +const TASK_SECTION_TEMPLATE: &str = include_str!("../../tmpls/task_section.md"); + +/// Maps a package to its per-OS pass/fail status. +type OsStatuses = BTreeMap<String, bool>; + +/// A row in a task section: item name and its per-OS statuses. +type TaskRow<'a> = (&'a String, &'a OsStatuses); + +/// Rows grouped by task name. +type RowsByTask<'a> = BTreeMap<&'a String, Vec<TaskRow<'a>>>; + +#[command(node = "report-collect")] +pub fn report_collect(logs: &ResCollectLogs) -> Next { + if !PathBuf::from(COLLECT_DIR).is_dir() { + return ErrorNoCollectDir.to_chain(); + } + + // Group rows by task: task -> [(item, os_statuses)]. + let by_task: RowsByTask = + logs.statuses + .iter() + .fold(BTreeMap::new(), |mut acc, ((task, item), os_statuses)| { + acc.entry(task).or_default().push((item, os_statuses)); + acc + }); + + // Render one section per task (table rows + this task's failures). + let mut fail_count = 0; + let mut sections: Vec<HashMap<String, String>> = Vec::new(); + for (task, rows) in by_task { + let mut row_arms = Vec::new(); + let mut fail_arms = Vec::new(); + for (item, os_statuses) in rows { + let location = logs + .locations + .get(&(task.clone(), item.clone())) + .cloned() + .unwrap_or_default(); + row_arms.push(HashMap::from([ + ("item_name".to_string(), item.clone()), + ("location".to_string(), location), + ( + "pass_win".to_string(), + pass_cell(os_statuses.get("Windows")), + ), + ( + "pass_linux".to_string(), + pass_cell(os_statuses.get("Linux")), + ), + ("pass_mac".to_string(), pass_cell(os_statuses.get("MacOS"))), + ])); + + for (os, ok) in os_statuses { + if !ok { + let stdout = logs + .err_outputs + .get(&(task.clone(), os.clone(), item.clone())) + .cloned() + .unwrap_or_default(); + fail_arms.push(HashMap::from([ + ("item_name".to_string(), item.clone()), + ("stdout".to_string(), stdout), + ])); + fail_count += 1; + } + } + } + + let mut section = Template::from(TASK_SECTION_TEMPLATE); + section.insert_param("task_name".to_string(), task.clone()); + *section.add_impl("rows".to_string()) = row_arms; + *section.add_impl("fails".to_string()) = fail_arms; + sections.push(HashMap::from([( + "section".to_string(), + section.expand().unwrap_or_default(), + )])); + } + + let mut template = Template::from(REPORT_TEMPLATE); + + template.insert_param("date".to_string(), logs.git.date.clone()); + template.insert_param("commit_hash".to_string(), logs.git.commit_hash.clone()); + *template.add_impl("task_sections".to_string()) = sections; + + let expanded = template.expand().unwrap_or_default(); + let output = PathBuf::from(REPORT_PATH); + let parent = output.parent().expect("output path has a parent"); + + if let Err(e) = std::fs::create_dir_all(parent).and_then(|()| std::fs::write(&output, expanded)) + { + return ErrorReportWrite(format!("failed to write {}: {e}", output.display())).to_chain(); + } + + ResultCollectResults { output, fail_count }.to_chain() +} + +fn pass_cell(status: Option<&bool>) -> String { + match status { + Some(true) => "✅".to_string(), + Some(false) => "❌".to_string(), + None => "—".to_string(), + } +} + +/// The generated report. +#[derive(Grouped)] +pub struct ResultCollectResults { + pub output: PathBuf, + pub fail_count: usize, +} + +#[derive(Grouped, Default)] +pub struct ErrorNoCollectDir; + +#[derive(Grouped, Default)] +pub struct ErrorReportWrite(pub String); + +#[renderer(buffer)] +pub fn render_collect_results(r: ResultCollectResults) { + r_println!("Collected {} failing logs", r.fail_count); + r_println!("Report generated at {}", r.output.display()); +} + +#[renderer] +pub fn render_error_no_collect_dir(_: ErrorNoCollectDir, error: &CargoError) -> RenderResult { + let render_result = RenderResult::new(); + error.println(vec![format!("No collect directory: {COLLECT_DIR}")]); + render_result +} + +#[renderer] +pub fn render_error_report_write(e: ErrorReportWrite, error: &CargoError) -> RenderResult { + let render_result = RenderResult::new(); + error.println(vec![format!("Report: {}", e.0)]); + render_result +} diff --git a/mingling_ci/src/cmd/cmd_show_features.rs b/mingling_ci/src/cmd/cmd_show_features.rs new file mode 100644 index 0000000..5fff0c5 --- /dev/null +++ b/mingling_ci/src/cmd/cmd_show_features.rs @@ -0,0 +1,26 @@ +use mingling::{ + Grouped, + macros::{buffer, command, r_println, renderer}, +}; + +use crate::res::ResFeatureList; + +#[command(node = "show-features")] +pub fn show_features(features: &ResFeatureList) -> ResultShowFeatures { + ResultShowFeatures { + features: features.list.clone(), + } +} + +/// The docs.rs feature list of `mingling`. +#[derive(Grouped)] +pub struct ResultShowFeatures { + pub features: Vec<String>, +} + +#[renderer(buffer)] +pub fn render_show_features(r: ResultShowFeatures) { + for feature in r.features { + r_println!("{feature}"); + } +} diff --git a/mingling_ci/src/cmd/cmd_show_manifests.rs b/mingling_ci/src/cmd/cmd_show_manifests.rs new file mode 100644 index 0000000..2be82d2 --- /dev/null +++ b/mingling_ci/src/cmd/cmd_show_manifests.rs @@ -0,0 +1,71 @@ +use std::path::PathBuf; + +use mingling::{ + Grouped, + macros::{buffer, command, r_println, renderer}, +}; + +use prettytable::{ + Cell, Row, Table, + format::{FormatBuilder, LinePosition, LineSeparator}, +}; + +use crate::res::Manifests; + +#[command(node = "show-manifests")] +pub fn show_manifests(manifests: &Manifests) -> ResultPrintManifests { + let mut entries: Vec<ManifestEntry> = manifests + .package_dirs + .iter() + .map(|(name, path)| ManifestEntry { + name: name.clone(), + path: path.clone(), + }) + .collect(); + entries.sort_by(|a, b| a.path.cmp(&b.path)); + ResultPrintManifests { entries } +} + +/// All manifests the CI will check, sorted by path. +#[derive(Grouped)] +pub struct ResultPrintManifests { + pub entries: Vec<ManifestEntry>, +} + +#[derive(Debug, Clone)] +pub struct ManifestEntry { + pub name: String, + pub path: PathBuf, +} + +#[renderer(buffer)] +pub fn render_print_manifests(r: ResultPrintManifests) { + let mut table = Table::new(); + + table.set_format( + FormatBuilder::new() + .column_separator('│') + .borders('│') + .separator(LinePosition::Top, LineSeparator::new('─', '┬', '┌', '┐')) + .separator(LinePosition::Title, LineSeparator::new('─', '┼', '├', '┤')) + .separator(LinePosition::Bottom, LineSeparator::new('─', '┴', '└', '┘')) + .padding(1, 1) + .build(), + ); + + table.set_titles(Row::new(vec![ + Cell::new("#"), + Cell::new("Package-Name"), + Cell::new("Package-Path"), + ])); + + for (index, entry) in r.entries.iter().enumerate() { + table.add_row(Row::new(vec![ + Cell::new(&(index + 1).to_string()), + Cell::new(&entry.name), + Cell::new(&entry.path.to_string_lossy()), + ])); + } + + r_println!("{table}"); +} |
