use anyhow::{anyhow, bail, Context, Result};
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
pub(crate) const GUARD_ENV: &str = "GIT_QUEUE_IN_REQUEUE";
pub(crate) fn out(args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.output()
.with_context(|| format!("failed to spawn `git {}`", args.join(" ")))?;
if !output.status.success() {
bail!(
"`git {}` failed:\n{}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub(crate) fn run(args: &[&str]) -> Result<()> {
let status = Command::new("git")
.args(args)
.status()
.with_context(|| format!("failed to spawn `git {}`", args.join(" ")))?;
if !status.success() {
bail!("`git {}` exited with {}", args.join(" "), status);
}
Ok(())
}
pub(crate) fn ok(args: &[&str]) -> bool {
Command::new("git")
.args(args)
.output()
.is_ok_and(|o| o.status.success())
}
pub(crate) fn ensure_repo() -> Result<()> {
if !ok(&["rev-parse", "--git-dir"]) {
bail!("not inside a git repository (run this from within your repo)");
}
Ok(())
}
pub(crate) fn current_branch() -> Result<String> {
let b = out(&["rev-parse", "--abbrev-ref", "HEAD"])?;
if b == "HEAD" {
bail!("you are in a detached HEAD state; check out a branch first");
}
Ok(b)
}
pub(crate) fn rev_parse(rev: &str) -> Result<String> {
out(&["rev-parse", "--verify", "--quiet", rev])
.map_err(|_| anyhow!("cannot resolve revision `{rev}`"))
}
pub(crate) fn branch_exists(name: &str) -> bool {
ok(&[
"show-ref",
"--verify",
"--quiet",
&format!("refs/heads/{name}"),
])
}
pub(crate) fn is_ancestor(ancestor: &str, descendant: &str) -> bool {
ok(&["merge-base", "--is-ancestor", ancestor, descendant])
}
pub(crate) fn merge_base(a: &str, b: &str) -> Result<String> {
out(&["merge-base", a, b])
}
pub(crate) fn checkout(branch: &str) -> Result<()> {
run(&["checkout", branch])
}
pub(crate) fn checkout_quiet(branch: &str) -> Result<()> {
run(&["checkout", "-q", branch])
}
pub(crate) fn reset_hard_head() -> Result<()> {
run(&["reset", "-q", "--hard"])
}
pub(crate) fn create_branch(name: &str, start_point: &str) -> Result<()> {
run(&["branch", name, start_point])
}
pub(crate) fn tip_subject(branch: &str) -> Result<String> {
out(&["log", "-1", "--format=%s", branch])
}
pub(crate) fn ahead_count(base: &str, branch: &str) -> Result<usize> {
let s = out(&["rev-list", "--count", &format!("{base}..{branch}")])?;
Ok(s.parse().unwrap_or(0))
}
pub(crate) fn commits_between_with_ids(
base: &str,
tip: &str,
) -> Result<Vec<(String, Option<String>, String)>> {
let raw = out(&[
"log",
"--reverse",
&format!(
"--format=%H%x09%(trailers:key={},valueonly,separator=%x20)%x09%s",
crate::ident::TRAILER
),
&format!("{base}..{tip}"),
])?;
Ok(raw
.lines()
.filter_map(|l| {
let mut it = l.splitn(3, '\t');
let sha = it.next()?.to_string();
if sha.is_empty() {
return None;
}
let id = it
.next()
.and_then(|s| s.split_whitespace().next())
.map(str::to_string);
let subject = it.next().unwrap_or("").to_string();
Some((sha, id, subject))
})
.collect())
}
pub(crate) fn commit_diff(rev: &str) -> Result<String> {
out(&["show", "--no-color", "--format=", "--patch", rev])
}
pub(crate) fn commit_message(rev: &str) -> Result<String> {
out(&["show", "--no-patch", "--format=%B", rev])
}
const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
pub(crate) fn tree_of(rev: &str) -> Result<String> {
out(&["rev-parse", "--verify", &format!("{rev}^{{tree}}")])
}
pub(crate) fn file_at(rev: &str, path: &str) -> String {
out(&["show", &format!("{rev}:{path}")]).unwrap_or_default()
}
fn hash_object(content: &str) -> Result<String> {
let mut cmd = Command::new("git")
.args(["hash-object", "-w", "--stdin"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("failed to spawn `git hash-object`")?;
cmd.stdin
.take()
.ok_or_else(|| anyhow!("no stdin pipe for `git hash-object`"))?
.write_all(content.as_bytes())
.context("failed to write blob content")?;
let out = cmd.wait_with_output()?;
if !out.status.success() {
bail!("`git hash-object` failed");
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub(crate) fn build_tree(base_tree: &str, changes: &[(String, Option<String>)]) -> Result<String> {
let git_dir = out(&["rev-parse", "--git-dir"])?;
let index_path = std::path::Path::new(&git_dir).join("git-queue-split-index");
let index = index_path.to_string_lossy().to_string();
let run_indexed = |args: &[&str]| -> Result<()> {
let mut c = Command::new("git");
c.args(args).env("GIT_INDEX_FILE", &index);
quiet_git(&mut c);
let status = c.status().context("failed to spawn `git`")?;
if !status.success() {
bail!("`git {}` failed", args.join(" "));
}
Ok(())
};
let result = (|| {
run_indexed(&["read-tree", base_tree])?;
for (path, content) in changes {
match content {
Some(text) => {
let blob = hash_object(text)?;
run_indexed(&[
"update-index",
"--add",
"--cacheinfo",
&format!("100644,{blob},{path}"),
])?;
}
None => {
run_indexed(&["update-index", "--force-remove", path])?;
}
}
}
let mut c = Command::new("git");
c.args(["write-tree"])
.env("GIT_INDEX_FILE", &index)
.env(GUARD_ENV, "1")
.stderr(Stdio::null());
let out = c.output().context("failed to spawn `git write-tree`")?;
if !out.status.success() {
bail!("`git write-tree` failed");
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
})();
let _ = std::fs::remove_file(&index_path);
result
}
pub(crate) fn commit_tree(tree: &str, parent: &str, message: &str) -> Result<String> {
let mut cmd = Command::new("git")
.args(["commit-tree", tree, "-p", parent])
.env(GUARD_ENV, "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("failed to spawn `git commit-tree`")?;
cmd.stdin
.take()
.ok_or_else(|| anyhow!("no stdin pipe for `git commit-tree`"))?
.write_all(message.as_bytes())
.context("failed to write commit message")?;
let out = cmd.wait_with_output()?;
if !out.status.success() {
bail!("`git commit-tree` failed");
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub(crate) fn cherry_pick_onto(base: &str, sha: &str) -> Result<String> {
run(&["checkout", "-q", "--detach", base])?;
let mut pick = Command::new("git");
pick.args(["cherry-pick", "--allow-empty", sha]);
quiet_git(&mut pick);
let status = pick.status().context("failed to spawn `git cherry-pick`")?;
if !status.success() {
if cherry_pick_in_progress() {
let _ = Command::new("git")
.args(["cherry-pick", "--abort"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
bail!("replaying a split descendant unexpectedly conflicted");
}
out(&["rev-parse", "HEAD"])
}
pub(crate) fn commit_is_empty(rev: &str) -> bool {
let parent = out(&["rev-parse", "--verify", "--quiet", &format!("{rev}^")])
.unwrap_or_else(|_| EMPTY_TREE.to_string());
ok(&["diff", "--quiet", &parent, rev])
}
pub(crate) fn commits_between(base: &str, tip: &str) -> Result<Vec<(String, String)>> {
let raw = out(&[
"log",
"--reverse",
"--format=%H%x09%s",
&format!("{base}..{tip}"),
])?;
Ok(raw
.lines()
.filter_map(|l| {
let (sha, subject) = l.split_once('\t')?;
Some((sha.to_string(), subject.to_string()))
})
.collect())
}
pub(crate) fn tracked_clean() -> bool {
out(&["status", "--porcelain", "--untracked-files=no"]).is_ok_and(|s| s.is_empty())
}
pub(crate) fn worktree_clean() -> bool {
out(&["status", "--porcelain"]).is_ok_and(|s| s.is_empty())
}
pub(crate) fn detach_head() -> Result<()> {
run(&["checkout", "-q", "--detach"])
}
pub(crate) fn rebase_in_progress() -> bool {
let dir = match out(&["rev-parse", "--git-dir"]) {
Ok(d) => PathBuf::from(d),
Err(_) => return false,
};
dir.join("rebase-merge").exists() || dir.join("rebase-apply").exists()
}
pub(crate) fn fetch(remote: &str) -> Result<()> {
run(&["fetch", "--prune", remote])
}
pub(crate) fn remote_branch(remote: &str, branch: &str) -> Option<String> {
let r = format!("{remote}/{branch}");
out(&["rev-parse", "--verify", "--quiet", &r])
.ok()
.filter(|s| !s.is_empty())
}
pub(crate) fn merge_ff_only(target: &str) -> Result<()> {
run(&["merge", "--ff-only", target])
}
pub(crate) fn push(remote: &str, branch: &str) -> Result<()> {
run(&["push", "--force-with-lease", "-u", remote, branch])
}
pub(crate) fn force_ref(branch: &str, sha: &str) -> Result<()> {
run(&["update-ref", &format!("refs/heads/{branch}"), sha])
}
pub(crate) fn staged_changes() -> bool {
!ok(&["diff", "--cached", "--quiet"])
}
pub(crate) fn github_repo_url(remote: &str) -> Option<String> {
let url = out(&["remote", "get-url", remote]).ok()?;
let path = url
.strip_prefix("git@github.com:")
.or_else(|| url.strip_prefix("ssh://git@github.com/"))
.or_else(|| url.strip_prefix("https://github.com/"))?;
let path = path
.strip_suffix(".git")
.unwrap_or(path)
.trim_end_matches('/');
Some(format!("https://github.com/{path}"))
}
pub(crate) fn conflict_files(rev: &str) -> Vec<String> {
out(&["grep", "-I", "-l", "-e", "^<<<<<<< ", rev])
.map(|raw| {
raw.lines()
.filter_map(|l| l.split_once(':').map(|(_, p)| p.to_string()))
.collect()
})
.unwrap_or_default()
}
pub(crate) fn has_conflict_markers(rev: &str) -> bool {
ok(&["grep", "-I", "-l", "-e", "^<<<<<<< ", rev])
}
pub(crate) fn commit(message: Option<&str>) -> Result<()> {
let mut cmd = Command::new("git");
cmd.env(GUARD_ENV, "1");
match message {
Some(m) => cmd.args(["commit", "-m", m]),
None => cmd.args(["commit"]),
};
let status = cmd.status().context("failed to spawn `git commit`")?;
if !status.success() {
bail!("`git commit` failed");
}
Ok(())
}
pub(crate) fn history_fixup(commit: &str) -> Result<bool> {
let out = Command::new("git")
.args(["history", "fixup", commit])
.env(GUARD_ENV, "1")
.output()
.context("failed to spawn `git history fixup`")?;
if out.status.success() {
return Ok(false);
}
let err = String::from_utf8_lossy(&out.stderr);
if err.contains("conflict") {
return Ok(true);
}
bail!("`git history fixup` failed:\n{}", err.trim());
}
pub(crate) fn history_reword(commit: &str) -> Result<bool> {
let status = Command::new("git")
.args(["history", "reword", commit])
.env(GUARD_ENV, "1")
.status()
.context("failed to spawn `git history reword`")?;
Ok(!status.success())
}
pub(crate) enum Replayed {
Applied,
Failed(String),
}
pub(crate) fn replay_requeue(onto: &str, ranges: &[String]) -> Result<Replayed> {
let mut args: Vec<String> = vec![
"replay".into(),
"--onto".into(),
onto.into(),
"--contained".into(),
];
args.extend(ranges.iter().cloned());
let argrefs: Vec<&str> = args.iter().map(std::string::String::as_str).collect();
let out = Command::new("git")
.args(&argrefs)
.env(GUARD_ENV, "1")
.output()
.context("failed to spawn `git replay`")?;
if !out.status.success() {
return Ok(Replayed::Failed(
String::from_utf8_lossy(&out.stderr).trim().to_string(),
));
}
if out.stdout.iter().all(u8::is_ascii_whitespace) {
return Ok(Replayed::Applied); }
let mut child = Command::new("git")
.args(["update-ref", "--stdin"])
.env(GUARD_ENV, "1")
.stdin(Stdio::piped())
.spawn()
.context("failed to spawn `git update-ref --stdin`")?;
child
.stdin
.take()
.ok_or_else(|| anyhow!("no stdin pipe for `git update-ref --stdin`"))?
.write_all(&out.stdout)
.context("writing replay plan to update-ref")?;
if !child.wait()?.success() {
bail!("failed to apply replay ref updates");
}
Ok(Replayed::Applied)
}
pub(crate) fn rebase_persist(onto: &str, upstream: &str, branch: &str) -> Result<()> {
let mut initial = Command::new("git");
initial.args([
"-c",
"core.editor=true",
"rebase",
"--update-refs",
"--onto",
onto,
upstream,
branch,
]);
quiet_git(&mut initial);
let _ = initial.status().context("failed to spawn `git rebase`")?;
drive_rebase_to_completion(branch)
}
pub(crate) fn rebase_reorder_persist(
base: &str,
top_branch: &str,
move_shas: &[String],
after: Option<&str>,
) -> Result<()> {
let exe = std::env::current_exe().context("cannot locate the git-queue executable")?;
let mut initial = Command::new("git");
initial.args([
"-c",
"core.editor=true",
"rebase",
"-i",
"--update-refs",
"--empty=keep",
"--onto",
base,
base,
top_branch,
]);
quiet_git(&mut initial);
initial
.env(
"GIT_SEQUENCE_EDITOR",
format!("\"{}\" reorder-todo", exe.display()),
)
.env("GIT_QUEUE_MOVE_SHAS", move_shas.join(" "))
.env("GIT_QUEUE_MOVE_AFTER", after.unwrap_or(""));
let _ = initial.status().context("failed to spawn `git rebase`")?;
drive_rebase_to_completion(top_branch)
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Rewrite {
Clean,
Conflict,
}
pub(crate) fn rebase_with_todo_stop(base: &str, top_branch: &str, todo: &str) -> Result<Rewrite> {
let git_dir = out(&["rev-parse", "--git-dir"])?;
let todo_path = std::path::Path::new(&git_dir).join("git-queue-todo");
std::fs::write(&todo_path, todo).context("failed to stage the rebase todo")?;
let editor = format!("cp {}", shell_single_quote(&todo_path.to_string_lossy()));
let mut cmd = Command::new("git");
cmd.args([
"rebase",
"-i",
"--update-refs",
"--empty=keep",
"--onto",
base,
base,
top_branch,
]);
cmd.stdout(Stdio::null())
.stderr(Stdio::null())
.env("GIT_EDITOR", "true")
.env(GUARD_ENV, "1")
.env("GIT_SEQUENCE_EDITOR", editor);
let status = cmd.status().context("failed to spawn `git rebase`")?;
let _ = std::fs::remove_file(&todo_path);
if status.success() {
Ok(Rewrite::Clean)
} else if rebase_in_progress() {
Ok(Rewrite::Conflict)
} else {
bail!("the rebase could not start (no rebase in progress)");
}
}
fn shell_single_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
pub(crate) fn rebase_with_todo_message(
base: &str,
top_branch: &str,
todo: &str,
message: &str,
) -> Result<()> {
let git_dir = out(&["rev-parse", "--git-dir"])?;
let dir = std::path::Path::new(&git_dir);
let todo_path = dir.join("git-queue-todo");
let msg_path = dir.join("git-queue-msg");
std::fs::write(&todo_path, todo).context("failed to stage the rebase todo")?;
std::fs::write(&msg_path, message).context("failed to stage the commit message")?;
let seq_editor = format!("cp {}", shell_single_quote(&todo_path.to_string_lossy()));
let msg_editor = format!("cp {}", shell_single_quote(&msg_path.to_string_lossy()));
let mut cmd = Command::new("git");
cmd.args([
"rebase",
"-i",
"--update-refs",
"--onto",
base,
base,
top_branch,
]);
cmd.stdout(Stdio::null())
.stderr(Stdio::null())
.env(GUARD_ENV, "1")
.env("GIT_SEQUENCE_EDITOR", seq_editor)
.env("GIT_EDITOR", msg_editor);
let status = cmd.status().context("failed to spawn `git rebase`")?;
let _ = std::fs::remove_file(&todo_path);
let _ = std::fs::remove_file(&msg_path);
if status.success() {
return Ok(());
}
if rebase_in_progress() {
let _ = rebase_abort();
}
bail!("the reword did not apply cleanly");
}
pub(crate) fn rebase_squash_stop(
base: &str,
top_branch: &str,
todo: &str,
message: &str,
) -> Result<Rewrite> {
let git_dir = out(&["rev-parse", "--git-dir"])?;
let dir = std::path::Path::new(&git_dir);
let todo_path = dir.join("git-queue-todo");
let msg_path = dir.join("git-queue-msg");
std::fs::write(&todo_path, todo).context("failed to stage the rebase todo")?;
std::fs::write(&msg_path, message).context("failed to stage the commit message")?;
let seq_editor = format!("cp {}", shell_single_quote(&todo_path.to_string_lossy()));
let msg_editor = format!("cp {}", shell_single_quote(&msg_path.to_string_lossy()));
let mut cmd = Command::new("git");
cmd.args([
"rebase",
"-i",
"--update-refs",
"--empty=keep",
"--onto",
base,
base,
top_branch,
]);
cmd.stdout(Stdio::null())
.stderr(Stdio::null())
.env(GUARD_ENV, "1")
.env("GIT_SEQUENCE_EDITOR", seq_editor)
.env("GIT_EDITOR", msg_editor);
let status = cmd.status().context("failed to spawn `git rebase`")?;
let _ = std::fs::remove_file(&todo_path);
let _ = std::fs::remove_file(&msg_path);
if status.success() {
Ok(Rewrite::Clean)
} else if rebase_in_progress() {
Ok(Rewrite::Conflict)
} else {
bail!("the squash could not start (no rebase in progress)");
}
}
pub(crate) fn rebase_abort() -> Result<()> {
let mut cmd = Command::new("git");
cmd.args(["rebase", "--abort"]);
quiet_git(&mut cmd);
let status = cmd
.status()
.context("failed to spawn `git rebase --abort`")?;
if !status.success() {
bail!("`git rebase --abort` failed");
}
Ok(())
}
pub(crate) fn cherry_pick_persist(branch: &str, shas: &[String]) -> Result<()> {
run(&["checkout", "-q", branch])?;
for sha in shas {
let mut pick = Command::new("git");
pick.args(["cherry-pick", "--allow-empty", sha]);
quiet_git(&mut pick);
let _ = pick.status().context("failed to spawn `git cherry-pick`")?;
drive_cherry_pick_to_completion(branch)?;
}
Ok(())
}
fn cherry_pick_in_progress() -> bool {
out(&["rev-parse", "--git-path", "CHERRY_PICK_HEAD"])
.is_ok_and(|p| std::path::Path::new(&p).exists())
}
fn drive_cherry_pick_to_completion(what: &str) -> Result<()> {
let mut guard = 0;
while cherry_pick_in_progress() {
guard += 1;
if guard > 5000 {
let _ = Command::new("git")
.args(["cherry-pick", "--abort"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
bail!("cherry-pick onto `{what}` did not converge; aborted");
}
let mut add = Command::new("git");
add.args(["add", "-A"]);
quiet_git(&mut add);
let _ = add.status();
let sub: &[&str] = if staged_changes() {
&["cherry-pick", "--continue"]
} else {
&["cherry-pick", "--skip"]
};
let mut step = Command::new("git");
step.args(sub);
quiet_git(&mut step);
let _ = step.status();
}
Ok(())
}
pub(crate) fn rebase_stamp_ids(upstream: &str, branch: &str, shas: &[String]) -> Result<()> {
let exe = std::env::current_exe().context("cannot locate the git-queue executable")?;
let exe = exe.display();
let mut initial = Command::new("git");
initial.args(["rebase", "-i", "--update-refs", upstream, branch]);
initial
.stdout(Stdio::null())
.stderr(Stdio::null())
.env(GUARD_ENV, "1")
.env("GIT_SEQUENCE_EDITOR", format!("\"{exe}\" stamp-todo"))
.env("GIT_EDITOR", format!("\"{exe}\" add-queue-id"))
.env("GIT_QUEUE_REWORD_SHAS", shas.join(" "))
.env("GIT_QUEUE_STAMP_ALL", "1");
let _ = initial.status().context("failed to spawn `git rebase`")?;
drive_rebase_to_completion(branch)
}
fn quiet_git(c: &mut Command) {
c.stdout(Stdio::null())
.stderr(Stdio::null())
.env("GIT_EDITOR", "true")
.env(GUARD_ENV, "1");
}
fn drive_rebase_to_completion(what: &str) -> Result<()> {
let mut guard = 0;
while rebase_in_progress() {
guard += 1;
if guard > 5000 {
let _ = Command::new("git")
.args(["rebase", "--abort"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
bail!("requeue of `{what}` did not converge; aborted the rebase");
}
let mut add = Command::new("git");
add.args(["add", "-A"]);
quiet_git(&mut add);
let _ = add.status();
let sub: &[&str] = if staged_changes() {
&["rebase", "--continue"]
} else {
&["rebase", "--skip"]
};
let mut step = Command::new("git");
step.args(sub);
quiet_git(&mut step);
let _ = step.status();
}
Ok(())
}
pub(crate) fn log_messages(range: &str) -> Result<String> {
out(&["log", "--format=%B", range])
}
pub(crate) fn add_trailer_to_file(path: &std::path::Path, id: &str) -> Result<()> {
run(&[
"interpret-trailers",
"--if-exists",
"doNothing",
"--trailer",
&format!("{}: {id}", crate::ident::TRAILER),
"--in-place",
&path.to_string_lossy(),
])
}
pub(crate) fn queue_ids(range: &str) -> Result<Vec<(String, Option<String>)>> {
let raw = out(&[
"log",
"--reverse",
&format!(
"--format=%H%x09%(trailers:key={},valueonly,separator=%x20)",
crate::ident::TRAILER
),
range,
])?;
Ok(raw
.lines()
.map(|l| {
let (sha, id) = l.split_once('\t').unwrap_or((l, ""));
let id = id.split_whitespace().next().map(str::to_string);
(sha.to_string(), id)
})
.collect())
}
pub(crate) fn commits_with_ids(range: &str) -> Result<Vec<(Option<String>, String)>> {
let raw = out(&[
"log",
&format!(
"--format=|%(trailers:key={},valueonly,separator=%x20)%x09%s",
crate::ident::TRAILER
),
range,
])?;
Ok(raw
.lines()
.filter_map(|l| {
let (id, subject) = l.strip_prefix('|')?.split_once('\t')?;
let id = id.split_whitespace().next().map(str::to_string);
Some((id, subject.to_string()))
})
.collect())
}
pub(crate) fn queue_id_of(rev: &str) -> Option<String> {
out(&[
"log",
"-1",
&format!(
"--format=%(trailers:key={},valueonly,separator=%x20)",
crate::ident::TRAILER
),
rev,
])
.ok()
.and_then(|s| s.split_whitespace().next().map(str::to_string))
}
pub(crate) fn amend_head_add_queue_id(id: &str) -> Result<()> {
let msg = out(&["log", "-1", "--format=%B", "HEAD"])?;
let tmp = std::env::temp_dir().join(format!("git-queue-msg-{}", std::process::id()));
std::fs::write(&tmp, msg + "\n").context("writing temp commit message")?;
add_trailer_to_file(&tmp, id)?;
let res = run(&[
"commit",
"--amend",
"--no-verify",
"--allow-empty",
"-q",
"-F",
&tmp.to_string_lossy(),
]);
let _ = std::fs::remove_file(&tmp);
res
}
pub(crate) fn cherry_fresh(upstream: &str, head: &str) -> Result<Vec<String>> {
let raw = out(&["cherry", upstream, head])?;
Ok(raw
.lines()
.filter_map(|l| l.strip_prefix("+ ").map(str::to_string))
.collect())
}
pub(crate) fn was_previous_position(branch: &str, sha: &str) -> bool {
out(&[
"reflog",
"show",
"--format=%H",
&format!("refs/heads/{branch}"),
])
.is_ok_and(|log| log.lines().any(|l| l == sha))
}
pub(crate) fn remote_trunk(remote: &str, trunk: &str) -> Option<String> {
let r = format!("{remote}/{trunk}");
if ok(&[
"show-ref",
"--verify",
"--quiet",
&format!("refs/remotes/{r}"),
]) {
Some(r)
} else {
None
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
#[test]
fn github_urls_parse_from_both_remote_forms() {
for (input, want) in [
(
"git@github.com:freshtonic/git-queue.git",
"https://github.com/freshtonic/git-queue",
),
(
"https://github.com/freshtonic/git-queue",
"https://github.com/freshtonic/git-queue",
),
("ssh://git@github.com/o/r.git", "https://github.com/o/r"),
] {
let path = input
.strip_prefix("git@github.com:")
.or_else(|| input.strip_prefix("ssh://git@github.com/"))
.or_else(|| input.strip_prefix("https://github.com/"))
.unwrap();
let path = path
.strip_suffix(".git")
.unwrap_or(path)
.trim_end_matches('/');
assert_eq!(format!("https://github.com/{path}"), want);
}
}
}