diff options
| -rw-r--r-- | mingling_ci/help.txt | 3 | ||||
| -rw-r--r-- | mingling_ci/src/cmd.rs | 2 | ||||
| -rw-r--r-- | mingling_ci/src/cmd/cmd_git_lock.rs | 60 | ||||
| -rw-r--r-- | mingling_ci/src/cmd/cmd_git_unlock.rs | 63 | ||||
| -rw-r--r-- | mingling_ci/src/git.rs | 57 | ||||
| -rw-r--r-- | mingling_ci/src/lib.rs | 1 |
6 files changed, 186 insertions, 0 deletions
diff --git a/mingling_ci/help.txt b/mingling_ci/help.txt index 77d86d9..de006bb 100644 --- a/mingling_ci/help.txt +++ b/mingling_ci/help.txt @@ -11,6 +11,9 @@ COMMANDS: report-collect Collect and organize all inspection reports report-clean Clean up all reports + git-lock Temporarily commit the workspace for CI + git-unlock Restore the workspace after CI + show-features Print the docs.rs feature list of mingling show-manifests Print all crate paths that need to be checked diff --git a/mingling_ci/src/cmd.rs b/mingling_ci/src/cmd.rs index 30d65a1..b9a02dc 100644 --- a/mingling_ci/src/cmd.rs +++ b/mingling_ci/src/cmd.rs @@ -1,3 +1,5 @@ +pub(crate) mod cmd_git_lock; +pub(crate) mod cmd_git_unlock; pub(crate) mod cmd_report_clean; pub(crate) mod cmd_report_collect; pub(crate) mod cmd_show_features; 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..4398593 --- /dev/null +++ b/mingling_ci/src/cmd/cmd_git_lock.rs @@ -0,0 +1,60 @@ +use mingling::{ + Grouped, RenderResult, Routable, + macros::{buffer, command, r_println, renderer}, + res::ResExitCode, +}; + +use crate::Next; +use crate::git::{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), then commits everything with a `[DO NOT PUSH] CI TEMP` +/// message. When the tree has no tracked changes, a `MINGLING-CI-CHECKING` +/// marker file is created first so the commit is never empty. +#[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(); + } + + if worktree_clean() + && let Err(e) = std::fs::write(LOCK_FILE, "") + { + 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", TEMP_COMMIT_MESSAGE]) { + return ErrorGitLock(e).to_chain(); + } + + ResultGitLock {}.to_chain() +} + +#[derive(Grouped)] +pub struct ResultGitLock; + +#[derive(Grouped, Default)] +pub struct ErrorGitLock(pub String); + +#[renderer(buffer)] +pub fn render_git_lock(_: ResultGitLock) { + r_println!("Locked: CI temp commit created"); +} + +#[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..dadc318 --- /dev/null +++ b/mingling_ci/src/cmd/cmd_git_unlock.rs @@ -0,0 +1,63 @@ +use mingling::{ + Grouped, RenderResult, Routable, + macros::{buffer, command, r_println, renderer}, + res::ResExitCode, +}; + +use crate::Next; +use crate::git::{LOCK_FILE, TEMP_COMMIT_MARK, head_message, run_git}; +use crate::res::{CargoError, MessagePrinter}; + +/// Undoes a CI temporary commit created by [`crate::cmd::cmd_git_lock`]. +/// +/// Only acts when the HEAD commit message contains `CI TEMP` (case-sensitive), +/// which together with the `MINGLING-CI-CHECKING` marker means the workspace +/// is in a CI phase and all uncommitted state may be discarded. Restores the +/// tree in five steps: unstage, restore tracked files, delete untracked files, +/// roll back the temporary commit, and remove the marker file. +#[command(node = "git-unlock")] +pub fn git_unlock() -> 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(); + } + + if let Err(e) = undo_ci_phase() { + return ErrorGitUnlock(e).to_chain(); + } + + ResultGitUnlock {}.to_chain() +} + +/// The five-step restoration sequence of `git-unlock`. +fn undo_ci_phase() -> Result<(), String> { + run_git(["reset"])?; + run_git(["restore", "."])?; + run_git(["clean", "-f", "-d"])?; + run_git(["reset", "--hard", "HEAD~1"])?; + std::fs::remove_file(LOCK_FILE).ok(); + Ok(()) +} + +#[derive(Grouped)] +pub struct ResultGitUnlock; + +#[derive(Grouped, Default)] +pub struct ErrorGitUnlock(pub String); + +#[renderer(buffer)] +pub fn render_git_unlock(_: ResultGitUnlock) { + 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/git.rs b/mingling_ci/src/git.rs new file mode 100644 index 0000000..39f8976 --- /dev/null +++ b/mingling_ci/src/git.rs @@ -0,0 +1,57 @@ +//! Thin wrappers around the `git` CLI used by the CI phase lock/unlock pair. + +use std::ffi::OsStr; +use std::process::Command; + +/// Marker file created by `git-lock` when the working tree is clean, so that a +/// temporary commit can always be made; `git-unlock` removes it. Its presence +/// marks "CI phase in progress". +pub(crate) const LOCK_FILE: &str = "MINGLING-CI-CHECKING"; + +/// Temporary commit message used by `git-lock`. +pub(crate) const TEMP_COMMIT_MESSAGE: &str = "[DO NOT PUSH] CI TEMP [DO NOT PUSH]"; + +/// Case-sensitive substring that identifies a CI temporary commit in the HEAD +/// commit message. +pub(crate) const TEMP_COMMIT_MARK: &str = "CI TEMP"; + +/// Runs `git <args>`, returning stdout on success. +/// +/// # Errors +/// +/// Returns the git error message (stderr) when the command exits non-zero, or +/// when git itself cannot be spawned. +pub(crate) fn run_git<I, S>(args: I) -> Result<String, String> +where + I: IntoIterator<Item = S>, + S: AsRef<OsStr>, +{ + let output = Command::new("git") + .args(args) + .output() + .map_err(|e| format!("failed to run git: {e}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +/// Returns `true` when `git diff-index --quiet HEAD --` succeeds, i.e. the +/// working tree has no tracked changes. Git failures count as "not clean" so +/// the caller falls back to the marker-file path. +pub(crate) fn worktree_clean() -> bool { + Command::new("git") + .args(["diff-index", "--quiet", "HEAD", "--"]) + .status() + .is_ok_and(|status| status.success()) +} + +/// The subject line of the HEAD commit. +/// +/// # Errors +/// +/// Returns the git error message when the log command fails. +pub(crate) fn head_message() -> Result<String, String> { + run_git(["log", "-1", "--pretty=%s"]).map(|subject| subject.trim().to_string()) +} diff --git a/mingling_ci/src/lib.rs b/mingling_ci/src/lib.rs index 8ce8ce7..32a0cbd 100644 --- a/mingling_ci/src/lib.rs +++ b/mingling_ci/src/lib.rs @@ -6,6 +6,7 @@ use mingling::macros::{gen_program, help}; pub(crate) mod cmd; +pub(crate) mod git; pub(crate) mod task; /// Mingling CI's Resources |
