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
57pub fn is_merged_by_rev_list(repo: &Repository, base: &str, commit: &str) -> Result<bool> {
60 let range = format!("{}...{}", base, commit);
61 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 Ok(output.is_empty())
77}
78
79pub 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 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
121pub 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
205pub fn get_worktrees(repo: &Repository) -> Result<HashMap<LocalBranch, String>> {
207 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}