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
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
//
use crate as git;
use git_next_config as config;

pub type Result<T> = core::result::Result<T, Error>;

#[derive(Debug)]
pub struct Positions {
    pub main: git::Commit,
    pub next: git::Commit,
    pub dev: git::Commit,
    pub dev_commit_history: Vec<git::Commit>,
}

#[allow(clippy::cognitive_complexity)] // TODO: (#83) reduce complexity
pub fn validate_positions(
    open_repository: &dyn git::repository::OpenRepositoryLike,
    repo_details: &git::RepoDetails,
    repo_config: config::RepoConfig,
) -> Result<Positions> {
    let main_branch = repo_config.branches().main();
    let next_branch = repo_config.branches().next();
    let dev_branch = repo_config.branches().dev();
    tracing::debug!(%main_branch, %next_branch, %dev_branch, "branches");
    // Collect Commit Histories for `main`, `next` and `dev` branches
    open_repository.fetch()?;
    tracing::debug!("fetch okay");
    let commit_histories = get_commit_histories(open_repository, &repo_config)?;
    tracing::debug!(?commit_histories, "get commit histories okay");
    // branch tips
    let main =
        commit_histories.main.first().cloned().ok_or_else(|| {
            Error::NonRetryable(format!("Branch has no commits: {}", main_branch))
        })?;
    tracing::debug!("main branch okay");
    let next =
        commit_histories.next.first().cloned().ok_or_else(|| {
            Error::NonRetryable(format!("Branch has no commits: {}", next_branch))
        })?;
    tracing::debug!("next branch okay");
    let dev = commit_histories
        .dev
        .first()
        .cloned()
        .ok_or_else(|| Error::NonRetryable(format!("Branch has no commits: {}", dev_branch)))?;
    tracing::debug!("dev branch okay");
    // Validations:
    // Dev must be on main branch, else the USER must rebase it
    if is_not_based_on(&commit_histories.dev, &main) {
        tracing::warn!("Dev '{dev_branch}' not based on main '{main_branch}' - user must rebase",);
        return Err(Error::NonRetryable(format!(
            "Branch '{}' not based on '{}'",
            dev_branch, main_branch
        )));
    }
    tracing::debug!("dev based on main okay");
    // verify that next is on main or at most one commit on top of main, else reset it back to main
    if is_not_based_on(
        commit_histories
            .next
            .iter()
            .take(2)
            .cloned()
            .collect::<Vec<_>>()
            .as_slice(),
        &main,
    ) {
        tracing::info!("Main not on same commit as next, or it's parent - resetting next to main",);
        return reset_next_to_main(open_repository, repo_details, &main, &next, &next_branch);
    }
    tracing::debug!("next on or near main okay");
    // verify that next is an ancestor of dev, else reset it back to main
    if is_not_based_on(&commit_histories.dev, &next) {
        tracing::info!("Next is not an ancestor of dev - resetting next to main");
        return reset_next_to_main(open_repository, repo_details, &main, &next, &next_branch);
    }
    tracing::debug!("dev based on next okay");

    Ok(git::validation::positions::Positions {
        main,
        next,
        dev,
        dev_commit_history: commit_histories.dev,
    })
}

fn reset_next_to_main(
    open_repository: &dyn crate::repository::OpenRepositoryLike,
    repo_details: &crate::RepoDetails,
    main: &crate::Commit,
    next: &crate::Commit,
    next_branch: &config::BranchName,
) -> Result<Positions> {
    git::push::reset(
        open_repository,
        repo_details,
        next_branch,
        &main.clone().into(),
        &git::push::Force::From(next.clone().into()),
    )
    .map_err(|err| {
        Error::NonRetryable(format!(
            "Failed to reset branch '{next_branch}' to commit '{next}': {err}"
        ))
    })?;
    Err(Error::Retryable(format!(
        "Branch {next_branch} has been reset"
    )))
}

fn is_not_based_on(commits: &[crate::commit::Commit], needle: &crate::Commit) -> bool {
    commits.iter().filter(|commit| *commit == needle).count() == 0
}

fn get_commit_histories(
    open_repository: &dyn git::repository::OpenRepositoryLike,
    repo_config: &config::RepoConfig,
) -> git::commit::log::Result<git::commit::Histories> {
    let main = (open_repository.commit_log(&repo_config.branches().main(), &[]))?;
    let main_head = [main[0].clone()];
    let next = open_repository.commit_log(&repo_config.branches().next(), &main_head)?;
    let dev = open_repository.commit_log(&repo_config.branches().dev(), &main_head)?;
    let histories = git::commit::Histories { main, next, dev };
    Ok(histories)
}

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("{0} - will retry")]
    Retryable(String),

    #[error("{0} - not retrying")]
    NonRetryable(String),
}
impl From<git::fetch::Error> for Error {
    fn from(value: git::fetch::Error) -> Self {
        Self::Retryable(value.to_string())
    }
}
impl From<git::commit::log::Error> for Error {
    fn from(value: git::commit::log::Error) -> Self {
        Self::Retryable(value.to_string())
    }
}