git_trim/
subprocess.rs

1use std::collections::{HashMap, HashSet};
2use std::process::{Command, Stdio};
3
4use anyhow::{Context, Result};
5use git2::{Config, Reference, Repository};
6use log::*;
7
8use crate::branch::{LocalBranch, RemoteBranch, RemoteTrackingBranch, RemoteTrackingBranchStatus};
9
10fn git(repo: &Repository, args: &[&str], level: log::Level) -> Result<()> {
11    let workdir = repo.workdir().context("Bare repository is not supported")?;
12    let workdir = workdir.to_str().context("non utf-8 workdir")?;
13    log!(level, "> git {}", args.join(" "));
14
15    let mut cd_args = vec!["-C", workdir];
16    cd_args.extend_from_slice(args);
17    let exit_status = Command::new("git").args(cd_args).status()?;
18    if !exit_status.success() {
19        Err(std::io::Error::from_raw_os_error(exit_status.code().unwrap_or(-1)).into())
20    } else {
21        Ok(())
22    }
23}
24
25fn git_output(repo: &Repository, args: &[&str], level: log::Level) -> Result<String> {
26    let workdir = repo.workdir().context("Bare repository is not supported")?;
27    let workdir = workdir.to_str().context("non utf-8 workdir")?;
28    log!(level, "> git {}", args.join(" "));
29
30    let mut cd_args = vec!["-C", workdir];
31    cd_args.extend_from_slice(args);
32    let output = Command::new("git")
33        .args(cd_args)
34        .stdin(Stdio::null())
35        .stdout(Stdio::piped())
36        .output()?;
37    if !output.status.success() {
38        return Err(std::io::Error::from_raw_os_error(output.status.code().unwrap_or(-1)).into());
39    }
40
41    let str = std::str::from_utf8(&output.stdout)?.trim();
42    for line in str.lines() {
43        trace!("| {}", line);
44    }
45    Ok(str.to_string())
46}
47
48pub fn remote_update(repo: &Repository, dry_run: bool) -> Result<()> {
49    if !dry_run {
50        git(repo, &["remote", "update", "--prune"], Level::Info)
51    } else {
52        info!("> git remote update --prune (dry-run)");
53        Ok(())
54    }
55}
56
57/// Get whether there any commits are not in the `base` from the `commit`
58/// `git rev-list --cherry-pick --right-only --no-merges -n1 <base>..<commit>`
59pub fn is_merged_by_rev_list(repo: &Repository, base: &str, commit: &str) -> Result<bool> {
60    let range = format!("{}...{}", base, commit);
61    // Is there any revs that are not applied to the base in the branch?
62    let output = git_output(
63        repo,
64        &[
65            "rev-list",
66            "--cherry-pick",
67            "--right-only",
68            "--no-merges",
69            "-n1",
70            &range,
71        ],
72        Level::Trace,
73    )?;
74
75    // empty output means there aren't any revs that are not applied to the base.
76    Ok(output.is_empty())
77}
78
79/// Get branches that are merged with merge commit.
80/// `git branch --format '%(refname)' --merged <base>`
81pub fn get_noff_merged_locals(
82    repo: &Repository,
83    config: &Config,
84    bases: &[RemoteTrackingBranch],
85) -> Result<HashSet<LocalBranch>> {
86    let mut result = HashSet::new();
87    for base in bases {
88        let refnames = git_output(
89            repo,
90            &[
91                "branch",
92                "--format",
93                "%(refname)",
94                "--merged",
95                &base.refname,
96            ],
97            Level::Trace,
98        )?;
99        for refname in refnames.lines() {
100            if !refnames.starts_with("refs/") {
101                // Detached HEAD is printed as '(HEAD detached at 1234abc)'
102                continue;
103            }
104            let branch = LocalBranch::new(refname);
105            let upstream = branch.fetch_upstream(repo, config)?;
106            if let RemoteTrackingBranchStatus::Exists(upstream) = upstream {
107                if base == &upstream {
108                    continue;
109                }
110            }
111            let reference = repo.find_reference(refname)?;
112            if reference.symbolic_target().is_some() {
113                continue;
114            }
115            result.insert(branch);
116        }
117    }
118    Ok(result)
119}
120
121/// Get remote tracking branches that are merged with merge commit.
122/// `git branch --format '%(refname)' --remote --merged <base>`
123pub fn get_noff_merged_remotes(
124    repo: &Repository,
125    bases: &[RemoteTrackingBranch],
126) -> Result<HashSet<RemoteTrackingBranch>> {
127    let mut result = HashSet::new();
128    for base in bases {
129        let refnames = git_output(
130            repo,
131            &[
132                "branch",
133                "--format",
134                "%(refname)",
135                "--remote",
136                "--merged",
137                &base.refname,
138            ],
139            Level::Trace,
140        )?;
141        for refname in refnames.lines() {
142            let branch = RemoteTrackingBranch::new(refname);
143            if base == &branch {
144                continue;
145            }
146            let reference = repo.find_reference(refname)?;
147            if reference.symbolic_target().is_some() {
148                continue;
149            }
150            result.insert(branch);
151        }
152    }
153    Ok(result)
154}
155
156#[derive(Debug)]
157pub struct RemoteHead {
158    pub remote: String,
159    pub refname: String,
160    pub commit: String,
161}
162
163pub fn ls_remote_heads(repo: &Repository, remote_name: &str) -> Result<Vec<RemoteHead>> {
164    let mut result = Vec::new();
165    for line in git_output(repo, &["ls-remote", "--heads", remote_name], Level::Trace)?.lines() {
166        let records = line.split_whitespace().collect::<Vec<_>>();
167        let commit = records[0].to_string();
168        let refname = records[1].to_string();
169        result.push(RemoteHead {
170            remote: remote_name.to_owned(),
171            refname,
172            commit,
173        });
174    }
175    Ok(result)
176}
177
178pub fn ls_remote_head(repo: &Repository, remote_name: &str) -> Result<RemoteHead> {
179    let command = &["ls-remote", "--symref", remote_name, "HEAD"];
180    let lines = git_output(repo, command, Level::Trace)?;
181    let mut refname = None;
182    let mut commit = None;
183    for line in lines.lines() {
184        if line.starts_with("ref: ") {
185            refname = Some(
186                line["ref: ".len()..line.len() - "HEAD".len()]
187                    .trim()
188                    .to_owned(),
189            )
190        } else {
191            commit = line.split_whitespace().next().map(|x| x.to_owned());
192        }
193    }
194    if let (Some(refname), Some(commit)) = (refname, commit) {
195        Ok(RemoteHead {
196            remote: remote_name.to_owned(),
197            refname,
198            commit,
199        })
200    } else {
201        Err(anyhow::anyhow!("HEAD not found on {}", remote_name))
202    }
203}
204
205/// Get worktrees and its paths without HEAD
206pub fn get_worktrees(repo: &Repository) -> Result<HashMap<LocalBranch, String>> {
207    // TODO: `libgit2` has `git2_worktree_*` APIs. However it is not ported to `git2`. Use subprocess directly.
208    let mut result = HashMap::new();
209    let mut worktree = None;
210    let mut branch = None;
211    for line in git_output(repo, &["worktree", "list", "--porcelain"], Level::Trace)?.lines() {
212        if let Some(stripped) = line.strip_prefix("worktree ") {
213            worktree = Some(stripped.to_owned());
214        } else if let Some(stripped) = line.strip_prefix("branch ") {
215            branch = Some(LocalBranch::new(stripped));
216        } else if line.is_empty() {
217            if let (Some(worktree), Some(branch)) = (worktree.take(), branch.take()) {
218                result.insert(branch, worktree);
219            }
220        }
221    }
222
223    if let (Some(worktree), Some(branch)) = (worktree.take(), branch.take()) {
224        result.insert(branch, worktree);
225    }
226
227    let head = repo.head()?;
228    if head.is_branch() {
229        let head_branch = LocalBranch::new(head.name().context("non-utf8 head branch name")?);
230        result.remove(&head_branch);
231    }
232    Ok(result)
233}
234
235pub fn checkout(repo: &Repository, head: Reference, dry_run: bool) -> Result<()> {
236    let head_refname = head.name().context("non-utf8 head ref name")?;
237    if !dry_run {
238        git(repo, &["checkout", head_refname], Level::Info)
239    } else {
240        info!("> git checkout {} (dry-run)", head_refname);
241
242        println!("Note: switching to '{}' (dry run)", head_refname);
243        println!("You are in 'detached HED' state... blah blah...");
244        let commit = head.peel_to_commit()?;
245        let message = commit.message().context("non-utf8 head ref name")?;
246        println!(
247            "HEAD is now at {} {} (dry run)",
248            &commit.id().to_string()[..7],
249            message.lines().next().unwrap_or_default()
250        );
251        Ok(())
252    }
253}
254
255pub fn branch_delete(repo: &Repository, branches: &[&LocalBranch], dry_run: bool) -> Result<()> {
256    let mut args = vec!["branch", "--delete", "--force"];
257    let mut branch_names = Vec::new();
258    for branch in branches {
259        let reference = repo.find_reference(&branch.refname)?;
260        assert!(reference.is_branch());
261        let branch_name = reference.shorthand().context("non utf-8 branch name")?;
262        branch_names.push(branch_name.to_owned());
263    }
264    args.extend(branch_names.iter().map(|x| x.as_str()));
265
266    if !dry_run {
267        git(repo, &args, Level::Info)
268    } else {
269        info!("> git {} (dry-run)", args.join(" "));
270        for branch_name in branch_names {
271            println!("Delete branch {} (dry run).", branch_name);
272        }
273        Ok(())
274    }
275}
276
277pub fn push_delete(
278    repo: &Repository,
279    remote_name: &str,
280    remote_branches: &[&RemoteBranch],
281    dry_run: bool,
282) -> Result<()> {
283    assert!(remote_branches
284        .iter()
285        .all(|branch| branch.remote == remote_name));
286    let mut command = vec!["push", "--delete"];
287    if dry_run {
288        command.push("--dry-run");
289    }
290    command.push(remote_name);
291    for remote_branch in remote_branches {
292        command.push(&remote_branch.refname);
293    }
294    git(repo, &command, Level::Trace)
295}