aboutsummaryrefslogtreecommitdiff
path: root/mingling_ci/src/cmd/cmd_git_unlock.rs
diff options
context:
space:
mode:
Diffstat (limited to 'mingling_ci/src/cmd/cmd_git_unlock.rs')
-rw-r--r--mingling_ci/src/cmd/cmd_git_unlock.rs116
1 files changed, 116 insertions, 0 deletions
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
+}