up_rs/tasks/git/
status.rs

1//! Show repo status.
2use crate::tasks::git::branch::get_branch_name;
3use crate::tasks::git::branch::get_push_branch;
4use crate::tasks::git::cherry::unmerged_commits;
5use crate::tasks::git::errors::GitError as E;
6use crate::utils::files::to_utf8_path;
7use color_eyre::eyre::ensure;
8use color_eyre::eyre::eyre;
9use color_eyre::eyre::Result;
10use git2::BranchType;
11use git2::Config;
12use git2::ErrorCode;
13use git2::Repository;
14use git2::StatusOptions;
15use git2::Statuses;
16use git2::SubmoduleIgnore;
17use std::fmt::Write as _; // import without risk of name clashing
18use tracing::trace;
19use tracing::warn;
20
21/// Check the repo is clean, equivalent to running `git status --porcelain` and
22/// checking everything looks good.
23pub(super) fn ensure_repo_clean(repo: &Repository) -> Result<()> {
24    let statuses = repo_statuses(repo)?;
25    trace!("Repo statuses: '{}'", status_short(repo, &statuses)?);
26    ensure!(
27        statuses.is_empty(),
28        E::UncommittedChanges {
29            status: status_short(repo, &statuses)?
30        }
31    );
32    Ok(())
33}
34
35/// Warn if repo has unpushed changes.
36/// - warns for any uncommitted
37/// - warns for any stashed changes
38/// - warns for any commits not in @{push}
39/// - if no push, warns for any commits not in @{upstream}
40/// - warns for any branches with no @{upstream} or @{push}
41/// - warns for any unpushed fork branches.
42///
43/// This assumes that you have your git repos set up as follows:
44///
45/// - Your forks remote names contain the word 'fork'.
46/// - Your local branches have an `@{upstream}`, and if they are Pull Request branches, a `@{push}`
47///   branch (if you haven't yet pushed that triggers a warning).
48/// - Your forks have been cleaned of all branches except fork/HEAD, which points to fork/forkmain.
49pub(super) fn warn_for_unpushed_changes(
50    repo: &mut Repository,
51    user_git_config: &Config,
52) -> Result<()> {
53    // Warn for uncommitted changes.
54    {
55        let statuses = repo_statuses(repo)?;
56        if !statuses.is_empty() {
57            warn!("Uncommitted changes:\n{}", status_short(repo, &statuses)?);
58        }
59    }
60
61    // Warn for any stashed changes
62    {
63        let mut stash_messages = Vec::new();
64        repo.stash_foreach(|_index, message, _stash_id| {
65            stash_messages.push(message.to_owned());
66            true
67        })?;
68        if !stash_messages.is_empty() {
69            warn!("Stashed changes:\n{:#?}", stash_messages);
70        }
71    }
72
73    for branch in repo.branches(Some(BranchType::Local))? {
74        let branch = branch?.0;
75        let branch_name = get_branch_name(&branch)?;
76        if let Some(push_branch) = get_push_branch(repo, &branch_name, user_git_config)? {
77            // Warn for any commits not in @{push}
78            if unmerged_commits(repo, &push_branch, &branch)? {
79                warn!("Branch '{branch_name}' has changes that aren't in @{{push}}.",);
80            }
81        } else {
82            match branch.upstream() {
83                Ok(upstream_branch) => {
84                    // If no push, warn for any commits not in @{upstream}
85                    if unmerged_commits(repo, &upstream_branch, &branch)? {
86                        warn!("Branch '{branch_name}' has changes that aren't in @{{upstream}}.",);
87                    }
88                }
89                Err(e) if e.code() == ErrorCode::NotFound => {
90                    // Warn for any branches with no @{upstream} or @{push}
91                    warn!("Branch '{branch_name}' has no @{{upstream}} or @{{push}} branch.",);
92                }
93                Err(e) => {
94                    // Something else went wrong, raise error.
95                    return Err(e.into());
96                }
97            }
98        }
99    }
100
101    // List in-progress branches.
102    // git branch --remotes --list '*fork/*' | grep -v 'fork/forkmain'
103    let mut unmerged_branches = Vec::new();
104    for branch in repo.branches(Some(BranchType::Remote))? {
105        let branch = branch?.0;
106        let branch_name = get_branch_name(&branch)?;
107        // TODO(gib): allow user-customisable remote and branch names.
108
109        // Only match fork branches.
110        if branch_name.contains("fork")
111            // Ignore *fork*/HEAD.
112            && !branch_name.contains("HEAD")
113            // Ignore *fork*/forkmain (my default branch name).
114            && !branch_name.contains("forkmain")
115        {
116            unmerged_branches.push(
117                // fork/mybranch -> mybranch.
118                branch_name,
119            );
120        }
121    }
122    if !unmerged_branches.is_empty() {
123        warn!("Unmerged fork branches: {} .", unmerged_branches.join(" "),);
124    }
125
126    Ok(())
127}
128
129/// Returns `Ok(statuses)`, `statuses` should be an empty vec if the repo has no
130/// changes (i.e. `git status` would print `nothing to commit, working tree
131/// clean`. Returns an error if getting the repo status errors.
132///
133/// To bail using the statuses use `status_short(repo, &statuses)`.
134fn repo_statuses(repo: &Repository) -> Result<Statuses> {
135    let mut status_options = StatusOptions::new();
136    // Ignored files don't count as dirty, so don't include them.
137    status_options
138        .include_ignored(false)
139        .include_untracked(true);
140    Ok(repo.statuses(Some(&mut status_options))?)
141}
142
143/// Taken from the status example in git2-rs.
144/// This version of the output prefixes each path with two status columns and
145/// shows submodule status information.
146#[allow(clippy::too_many_lines, clippy::useless_let_if_seq)]
147fn status_short(repo: &Repository, statuses: &git2::Statuses) -> Result<String> {
148    let mut output = String::new();
149    for entry in statuses
150        .iter()
151        .filter(|e| e.status() != git2::Status::CURRENT)
152    {
153        let mut index_status = match entry.status() {
154            s if s.contains(git2::Status::INDEX_NEW) => 'A',
155            s if s.contains(git2::Status::INDEX_MODIFIED) => 'M',
156            s if s.contains(git2::Status::INDEX_DELETED) => 'D',
157            s if s.contains(git2::Status::INDEX_RENAMED) => 'R',
158            s if s.contains(git2::Status::INDEX_TYPECHANGE) => 'T',
159            _ => ' ',
160        };
161        let mut worktree_status = match entry.status() {
162            s if s.contains(git2::Status::WT_NEW) => {
163                if index_status == ' ' {
164                    index_status = '?';
165                }
166                '?'
167            }
168            s if s.contains(git2::Status::WT_MODIFIED) => 'M',
169            s if s.contains(git2::Status::WT_DELETED) => 'D',
170            s if s.contains(git2::Status::WT_RENAMED) => 'R',
171            s if s.contains(git2::Status::WT_TYPECHANGE) => 'T',
172            _ => ' ',
173        };
174
175        if entry.status().contains(git2::Status::IGNORED) {
176            index_status = '!';
177            worktree_status = '!';
178        }
179        if index_status == '?' && worktree_status == '?' {
180            continue;
181        }
182        let mut extra = "";
183
184        // A commit in a tree is how submodules are stored, so let's go take a
185        // look at its status.
186        //
187        // TODO: check for GIT_FILEMODE_COMMIT
188        let status = entry.index_to_workdir().and_then(|diff| {
189            let ignore = SubmoduleIgnore::Unspecified;
190            diff.new_file()
191                .path_bytes()
192                .and_then(|s| std::str::from_utf8(s).ok())
193                .and_then(|name| repo.submodule_status(name, ignore).ok())
194        });
195        if let Some(status) = status {
196            if status.contains(git2::SubmoduleStatus::WD_MODIFIED) {
197                extra = " (new commits)";
198            } else if status.contains(git2::SubmoduleStatus::WD_INDEX_MODIFIED)
199                || status.contains(git2::SubmoduleStatus::WD_WD_MODIFIED)
200            {
201                extra = " (modified content)";
202            } else if status.contains(git2::SubmoduleStatus::WD_UNTRACKED) {
203                extra = " (untracked content)";
204            }
205        }
206
207        let (mut a, mut b, mut c) = (None, None, None);
208        if let Some(diff) = entry.head_to_index() {
209            a = diff.old_file().path();
210            b = diff.new_file().path();
211        }
212        if let Some(diff) = entry.index_to_workdir() {
213            a = a.or_else(|| diff.old_file().path());
214            b = b.or_else(|| diff.old_file().path());
215            c = diff.new_file().path();
216        }
217        let a = to_utf8_path(a.ok_or_else(|| eyre!("Couldn't work out diff status a"))?)?;
218        let b = to_utf8_path(b.ok_or_else(|| eyre!("Couldn't work out diff status b"))?)?;
219        let c = to_utf8_path(c.ok_or_else(|| eyre!("Couldn't work out diff status c"))?)?;
220
221        output += &match (index_status, worktree_status) {
222            ('R', 'R') => format!("RR {a} {b} {c}{extra}\n"),
223            ('R', worktree_status) => format!("R{worktree_status} {a} {b}{extra}\n"),
224            (index_status, 'R') => {
225                format!("{index_status}R {a} {c}{extra}\n")
226            }
227            (index_status, worktree_status) => {
228                format!("{index_status}{worktree_status} {a}{extra}\n")
229            }
230        }
231    }
232
233    for entry in statuses
234        .iter()
235        .filter(|e| e.status() == git2::Status::WT_NEW)
236    {
237        _ = writeln!(
238            output,
239            "?? {}",
240            to_utf8_path(
241                entry
242                    .index_to_workdir()
243                    .ok_or_else(|| eyre!("Couldn't find the workdir for current status entry."))?
244                    .old_file()
245                    .path()
246                    .ok_or_else(|| eyre!("Couldn't work out path to old file."))?
247            )?
248        );
249    }
250    Ok(output)
251}