aboutsummaryrefslogtreecommitdiff
path: root/mingling_ci/src/cmd/cmd_git_unlock.rs
blob: 41efefc31ce0acc7164e566c9a6e91b7bb9c193a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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
}