use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{Context, Result, anyhow, bail};
static VERBOSE: AtomicBool = AtomicBool::new(false);
pub fn set_verbose(verbose: bool) {
VERBOSE.store(verbose, Ordering::Relaxed);
}
pub fn verbose() -> bool {
VERBOSE.load(Ordering::Relaxed)
}
pub fn current_branch() -> Result<String> {
output(&["symbolic-ref", "--quiet", "--short", "HEAD"])
.context("failed to determine current branch")
}
pub fn is_in_repo() -> bool {
Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.is_ok_and(|out| out.status.success() && out.stdout.starts_with(b"true"))
}
pub fn local_branches() -> Result<Vec<String>> {
let output = output(&["for-each-ref", "--format=%(refname:short)", "refs/heads"])?;
Ok(output.lines().map(str::to_owned).collect())
}
pub fn git_path(path: &str) -> Result<String> {
output(&["rev-parse", "--git-path", path])
}
pub fn repo_root() -> Result<std::path::PathBuf> {
Ok(std::path::PathBuf::from(output(&[
"rev-parse",
"--show-toplevel",
])?))
}
pub fn git_common_path(path: &str) -> Result<String> {
let common_dir = output(&["rev-parse", "--git-common-dir"])?;
Ok(std::path::Path::new(&common_dir)
.join(path)
.to_string_lossy()
.into_owned())
}
pub fn worktree_branches() -> Result<Vec<(String, std::path::PathBuf)>> {
let porcelain = output(&["worktree", "list", "--porcelain"])?;
Ok(parse_worktree_branches(
&porcelain,
repo_root().ok().as_deref(),
))
}
pub fn worktree_add_detached(path: &std::path::Path, commit: &str) -> Result<()> {
let path = path.to_string_lossy().into_owned();
status(&[
"worktree", "add", "--detach", "--force", "--quiet", &path, commit,
])
.with_context(|| format!("failed to create a worktree at {path}"))
}
pub fn worktree_add_new_branch(path: &std::path::Path, branch: &str, start: &str) -> Result<()> {
let path = path.to_string_lossy().into_owned();
status(&["worktree", "add", "--quiet", "-b", branch, &path, start])
.with_context(|| format!("failed to create a worktree for {branch} at {path}"))
}
pub fn worktree_has_changes(path: &std::path::Path) -> bool {
let dir = path.to_string_lossy().into_owned();
output(&["-C", &dir, "status", "--porcelain"]).map_or(true, |out| !out.is_empty())
}
pub fn worktree_remove(path: &std::path::Path) -> Result<()> {
let path = path.to_string_lossy().into_owned();
status(&["worktree", "remove", "--force", &path])
.with_context(|| format!("failed to remove the worktree at {path}"))
}
pub fn checkout_detached_in(worktree: &std::path::Path, commit: &str) -> Result<()> {
let dir = worktree.to_string_lossy().into_owned();
status(&["-C", &dir, "checkout", "--detach", "--quiet", commit])
.with_context(|| format!("failed to check out {commit} in {dir}"))
}
pub fn git_common_path_absolute(path: &str) -> Result<std::path::PathBuf> {
let joined = git_common_path(path)?;
std::path::absolute(&joined).with_context(|| format!("failed to resolve {joined}"))
}
pub fn worktree_holding(branch: &str) -> Result<Option<std::path::PathBuf>> {
Ok(worktree_branches()?
.into_iter()
.find(|(name, _)| name == branch)
.map(|(_, path)| path))
}
pub fn in_linked_worktree() -> bool {
repo_root().is_ok_and(|root| !is_main_worktree(&root))
}
pub fn is_main_worktree(path: &std::path::Path) -> bool {
main_worktree().is_some_and(|main| same_path(&main, path))
}
fn main_worktree() -> Option<std::path::PathBuf> {
parse_main_worktree(&output(&["worktree", "list", "--porcelain"]).ok()?)
}
pub fn main_worktree_root() -> Result<std::path::PathBuf> {
match main_worktree() {
Some(path) => Ok(path),
None => repo_root(),
}
}
fn parse_main_worktree(porcelain: &str) -> Option<std::path::PathBuf> {
porcelain
.lines()
.find_map(|line| line.strip_prefix("worktree "))
.map(std::path::PathBuf::from)
}
pub fn detach_command(path: &std::path::Path) -> String {
format!("git -C \"{}\" checkout --detach", display_path(path))
}
pub fn describe_worktree(path: &std::path::Path) -> String {
let shown = display_path(path);
if is_main_worktree(path) {
format!("{shown} (the main worktree)")
} else {
shown
}
}
pub fn distinct_paths<'a>(
paths: impl IntoIterator<Item = &'a std::path::Path>,
) -> Vec<std::path::PathBuf> {
let mut distinct: Vec<std::path::PathBuf> = Vec::new();
for path in paths {
if !distinct.iter().any(|seen| same_path(seen, path)) {
distinct.push(path.to_path_buf());
}
}
distinct
}
fn parse_worktree_branches(
porcelain: &str,
current: Option<&std::path::Path>,
) -> Vec<(String, std::path::PathBuf)> {
let current = current.map(canonical);
let mut held = Vec::new();
let mut path: Option<std::path::PathBuf> = None;
for line in porcelain.lines() {
if let Some(rest) = line.strip_prefix("worktree ") {
path = Some(std::path::PathBuf::from(rest));
} else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
if let Some(path) = path.take()
&& current.as_deref() != Some(canonical(&path).as_path())
{
held.push((branch.to_owned(), path));
}
}
}
held
}
fn canonical(path: &std::path::Path) -> std::path::PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
pub fn same_path(a: &std::path::Path, b: &std::path::Path) -> bool {
canonical(a) == canonical(b)
}
pub fn display_path(path: &std::path::Path) -> String {
let Ok(cwd) = std::env::current_dir() else {
return path.display().to_string();
};
if let Ok(rest) = path.strip_prefix(&cwd)
&& rest.components().next().is_some()
{
return format!("./{}", rest.display());
}
if let Some(up) = cwd.parent()
&& let Ok(rest) = path.strip_prefix(up)
&& rest.components().next().is_some()
{
return format!("../{}", rest.display());
}
path.display().to_string()
}
pub fn remote_url(remote: &str) -> Result<Option<String>> {
output_codes(&["remote", "get-url", remote], &[2], "git remote get-url")
}
fn worktree_collision(branch: &str) -> Option<String> {
let path = worktree_holding(branch).ok().flatten()?;
Some(collision_message(
branch,
&display_path(&path),
is_main_worktree(&path),
))
}
fn collision_message(branch: &str, shown: &str, is_main: bool) -> String {
let mut free = format!("free it with `git -C \"{shown}\" checkout --detach`");
if !is_main {
free.push_str(&format!(
", or drop that worktree with `git worktree remove \"{shown}\"`"
));
}
format!(
"{branch} is checked out in the worktree at {shown}\n\
work on it there with `cd \"{shown}\"`, or {free}"
)
}
pub fn checkout(branch: &str) -> Result<()> {
checkout_silently(branch)?;
anstream::println!("switched to {}", switched_to(branch));
Ok(())
}
pub fn checkout_silently(branch: &str) -> Result<()> {
if let Some(message) = worktree_collision(branch) {
bail!(message);
}
status(&["switch", branch]).with_context(|| format!("failed to check out {branch}"))
}
pub fn switched_to(branch: &str) -> String {
crate::style::paint(crate::style::BRANCH, branch)
}
pub fn create_branch(branch: &str) -> Result<()> {
status(&["switch", "-c", branch]).with_context(|| format!("failed to create branch {branch}"))
}
pub fn create_branch_at(branch: &str, sha: &str) -> Result<()> {
status(&["branch", branch, sha])
.with_context(|| format!("failed to create branch {branch} at {sha}"))
}
pub fn delete_branch(branch: &str) -> Result<()> {
status(&["branch", "-D", branch]).with_context(|| format!("failed to delete branch {branch}"))
}
pub fn rename_branch(old: &str, new: &str) -> Result<()> {
status(&["branch", "-m", old, new]).with_context(|| format!("failed to rename {old} to {new}"))
}
pub fn fetch_branch(remote: &str, branch: &str) -> Result<()> {
let refspec = format!("{branch}:{branch}");
status(&["fetch", remote, &refspec])
.with_context(|| format!("failed to fetch {branch} from {remote}"))
}
pub fn pull_ff_only() -> Result<()> {
status(&["pull", "--ff-only"]).context("failed to fast-forward from the remote")
}
pub fn push_force_with_lease(remote: &str, branches: &[String]) -> Result<Vec<String>> {
let mut args = vec!["push", "--force-with-lease", remote];
args.extend(branches.iter().map(String::as_str));
run_lease_push(&args, remote, branches)
}
fn run_lease_push(args: &[&str], remote: &str, branches: &[String]) -> Result<Vec<String>> {
if verbose() {
status_passthrough(args).with_context(|| format!("failed to push branches to {remote}"))?;
return Ok(branches.to_vec());
}
let output = Command::new("git")
.args(args)
.output()
.context("failed to run git")?;
if output.status.success() {
return Ok(branches.to_vec());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if let Some(queued) = merge_queue_rejection(&stderr) {
anstream::eprintln!(
"{}",
crate::style::warn(&format!(
"{} {} in a merge queue and was not updated (dequeue its review to push it)",
queued.join(", "),
if queued.len() == 1 { "is" } else { "are" },
))
);
return Ok(landed_branches(branches, &queued));
}
if let Some(stale) = stale_rejection(&stderr) {
bail!(
"could not push {} to {remote}: the remote has moved on \
(a branch in the stack was likely merged or updated upstream)\n\
run `git stk sync` to reconcile your local stack with the remote, then try again",
stale.join(", "),
);
}
let _ = std::io::stdout().write_all(&output.stdout);
let _ = std::io::stderr().write_all(&output.stderr);
bail!(
"failed to push branches to {remote}: git exited with status {}",
output.status
)
}
fn landed_branches(attempted: &[String], held: &[String]) -> Vec<String> {
attempted
.iter()
.filter(|branch| !held.iter().any(|name| name == *branch))
.cloned()
.collect()
}
fn merge_queue_rejection(stderr: &str) -> Option<Vec<String>> {
let lower = stderr.to_lowercase();
let mentions_queue = lower.contains("merge queue") || lower.contains("queued for merging");
if !mentions_queue {
return None;
}
if ["stale info", "non-fast-forward", "fetch first"]
.iter()
.any(|marker| lower.contains(marker))
{
return None;
}
let rejected = rejected_refs(stderr);
if rejected.is_empty() {
None
} else {
Some(rejected)
}
}
fn stale_rejection(stderr: &str) -> Option<Vec<String>> {
let rejected: Vec<&str> = stderr
.lines()
.filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
.collect();
if rejected.is_empty() || !rejected.iter().all(|line| line_is_stale(line)) {
return None;
}
let names: Vec<String> = rejected
.iter()
.filter_map(|line| rejected_ref_name(line))
.collect();
if names.is_empty() { None } else { Some(names) }
}
fn line_is_stale(line: &str) -> bool {
let lower = line.to_lowercase();
["stale info", "non-fast-forward", "fetch first"]
.iter()
.any(|marker| lower.contains(marker))
}
fn rejected_ref_name(line: &str) -> Option<String> {
let after = line.split("-> ").nth(1)?;
Some(after.split_whitespace().next()?.to_owned())
}
fn rejected_refs(stderr: &str) -> Vec<String> {
stderr
.lines()
.filter(|line| line.contains("[remote rejected]") || line.contains("[rejected]"))
.filter_map(rejected_ref_name)
.collect()
}
pub fn push_set_upstream_force_with_lease(remote: &str, branches: &[String]) -> Result<()> {
let mut args = vec!["push", "--set-upstream", "--force-with-lease", remote];
args.extend(branches.iter().map(String::as_str));
run_lease_push(&args, remote, branches)?;
Ok(())
}
pub fn write_blob_ref(reference: &str, file: &str, content: &str) -> Result<()> {
let blob = output_with_stdin(&["hash-object", "-w", "--stdin"], content)
.context("failed to hash stack metadata")?;
let tree = output_with_stdin(&["mktree"], &format!("100644 blob {blob}\t{file}\n"))
.context("failed to write stack metadata tree")?;
let commit = output(&["commit-tree", &tree, "-m", "git-stk stack metadata"])
.context("failed to commit stack metadata")?;
status(&["update-ref", reference, &commit])
.with_context(|| format!("failed to update {reference}"))
}
pub fn push_ref(remote: &str, reference: &str) -> Result<()> {
status(&[
"push",
"--force",
remote,
&format!("{reference}:{reference}"),
])
.with_context(|| format!("failed to push {reference} to {remote}"))
}
pub fn fetch_ref(remote: &str, reference: &str) -> Result<()> {
status(&["fetch", remote, &format!("+{reference}:{reference}")])
.with_context(|| format!("failed to fetch {reference} from {remote}"))
}
pub fn read_ref_file(reference: &str, file: &str) -> Result<Option<String>> {
let output = Command::new("git")
.args(["cat-file", "blob", &format!("{reference}:{file}")])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.context("failed to run git cat-file")?;
if output.status.success() {
Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()))
} else {
Ok(None)
}
}
pub fn rebase(parent: &str, branch: &str, update_refs: bool) -> Result<()> {
if let Some(message) = worktree_collision(branch) {
bail!(message);
}
let mut args = vec!["rebase"];
if update_refs {
args.push("--update-refs");
}
args.extend([parent, branch]);
status(&args).with_context(|| format!("failed to rebase {branch} onto {parent}"))
}
pub fn rebase_onto(parent: &str, base: &str, branch: &str, update_refs: bool) -> Result<()> {
if let Some(message) = worktree_collision(branch) {
bail!(message);
}
let mut args = vec!["rebase"];
if update_refs {
args.push("--update-refs");
}
args.extend(["--onto", parent, base, branch]);
status(&args).with_context(|| format!("failed to rebase {branch} onto {parent} from {base}"))
}
pub fn rev_parse(rev: &str) -> Result<String> {
let spec = format!("{rev}^{{commit}}");
output(&["rev-parse", "--verify", &spec]).with_context(|| format!("failed to resolve {rev}"))
}
pub fn branch_sha(branch: &str) -> Option<String> {
rev_parse(branch).ok()
}
pub fn update_ref(branch: &str, sha: &str) -> Result<()> {
status(&["update-ref", &format!("refs/heads/{branch}"), sha])
.with_context(|| format!("failed to update {branch} to {sha}"))
}
pub fn reset_hard() -> Result<()> {
status(&["reset", "--hard"]).context("failed to reset the worktree")
}
pub fn worktree_is_clean() -> Result<bool> {
Ok(output(&["status", "--porcelain"])?.is_empty())
}
pub fn remote_default_branch(remote: &str) -> Option<String> {
let reference = format!("refs/remotes/{remote}/HEAD");
let full = output(&["symbolic-ref", "--short", &reference]).ok()?;
full.strip_prefix(&format!("{remote}/")).map(str::to_owned)
}
pub fn commits_behind(branch: &str, parent: &str) -> Result<usize> {
let range = format!("{branch}..{parent}");
let count = output(&["rev-list", "--count", &range])
.with_context(|| format!("failed to count commits in {range}"))?;
count
.trim()
.parse()
.context("failed to parse rev-list count")
}
pub fn merge_base(a: &str, b: &str) -> Result<String> {
output(&["merge-base", a, b])
.with_context(|| format!("failed to find merge base of {a} and {b}"))
}
pub fn diff_against_head(cached: bool) -> Result<String> {
let mut args = vec!["diff", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/"];
if cached {
args.push("--cached");
}
args.push("HEAD");
output(&args).context("failed to diff against HEAD")
}
pub fn blame_line_shas(file: &str, start: usize, len: usize) -> Result<Vec<String>> {
if len == 0 {
return Ok(Vec::new());
}
let range = format!("{start},{}", start + len - 1);
let out = output(&[
"blame",
"HEAD",
"-L",
&range,
"--line-porcelain",
"--",
file,
])
.with_context(|| format!("failed to blame {file}"))?;
let mut shas = Vec::new();
for line in out.lines() {
let token = line.split(' ').next().unwrap_or_default();
if token.len() == 40
&& token.bytes().all(|byte| byte.is_ascii_hexdigit())
&& !shas.iter().any(|seen| seen == token)
{
shas.push(token.to_owned());
}
}
Ok(shas)
}
pub fn rev_list(range: &str) -> Result<Vec<String>> {
Ok(output(&["rev-list", range])
.with_context(|| format!("failed to list commits in {range}"))?
.lines()
.map(str::to_owned)
.collect())
}
pub fn log_oneline(range: &str) -> Result<Vec<(String, String)>> {
Ok(output(&["log", "--format=%h%x09%s", range])
.with_context(|| format!("failed to log {range}"))?
.lines()
.filter_map(|line| {
line.split_once('\t')
.map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
})
.collect())
}
pub fn commit_subject(sha: &str) -> Result<String> {
output(&["show", "--no-patch", "--format=%s", sha])
.with_context(|| format!("failed to read subject of {sha}"))
}
pub fn commit_body(sha: &str) -> Result<String> {
output(&["show", "--no-patch", "--format=%b", sha])
.with_context(|| format!("failed to read body of {sha}"))
}
pub fn apply_cached(patch: &str) -> Result<()> {
let mut child = Command::new("git")
.args(["apply", "--cached", "--unidiff-zero"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to run git apply")?;
{
let mut stdin = child.stdin.take().context("git apply has no stdin")?;
stdin
.write_all(patch.as_bytes())
.context("failed to write patch to git apply")?;
}
let output = child
.wait_with_output()
.context("failed to run git apply")?;
if output.status.success() {
Ok(())
} else {
Err(command_error("git apply", &output.stderr))
}
}
pub fn commit_fixup(sha: &str) -> Result<()> {
status(&["commit", "--no-verify", &format!("--fixup={sha}")])
.with_context(|| format!("failed to create fixup commit for {sha}"))
}
pub fn reset_index() -> Result<()> {
status(&["reset", "--quiet"]).context("failed to reset the index")
}
pub fn reset_soft(sha: &str) -> Result<()> {
status(&["reset", "--soft", sha]).with_context(|| format!("failed to reset to {sha}"))
}
pub fn stash_push() -> Result<()> {
status(&["stash", "push", "--quiet"]).context("failed to stash changes")
}
pub fn stash_pop() -> Result<()> {
status(&["stash", "pop", "--quiet"]).context("failed to restore stashed changes")
}
pub fn rebase_autosquash(base: &str, update_refs: bool) -> Result<()> {
let mut args = vec!["rebase", "--interactive", "--autosquash"];
if update_refs {
args.push("--update-refs");
}
args.push(base);
let output = Command::new("git")
.args(&args)
.env("GIT_SEQUENCE_EDITOR", "true")
.env("GIT_EDITOR", "true")
.output()
.context("failed to run git rebase")?;
if output.status.success() {
Ok(())
} else {
Err(command_error("git rebase --autosquash", &output.stderr))
}
}
pub fn is_ancestor(ancestor: &str, descendant: &str) -> Result<bool> {
Ok(output_codes(
&["merge-base", "--is-ancestor", ancestor, descendant],
&[1],
"git merge-base --is-ancestor",
)?
.is_some())
}
pub fn diff_numstat(base: &str, branch: &str) -> Result<(usize, usize)> {
let output = output(&["diff", "--numstat", &format!("{base}...{branch}")])?;
let mut added = 0;
let mut deleted = 0;
for line in output.lines() {
let mut columns = line.split('\t');
added += column_count(columns.next());
deleted += column_count(columns.next());
}
Ok((added, deleted))
}
fn column_count(column: Option<&str>) -> usize {
column
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0)
}
pub fn supports_rebase_update_refs() -> Result<bool> {
let output = Command::new("git")
.args(["rebase", "-h"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.context("failed to inspect git rebase help")?;
let help = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
Ok(help_mentions_update_refs(&help))
}
fn help_mentions_update_refs(help: &str) -> bool {
help.contains("update-refs")
}
pub fn rebase_in_progress() -> bool {
["rebase-merge", "rebase-apply"].iter().any(|dir| {
git_path(dir)
.map(|path| std::path::Path::new(&path).exists())
.unwrap_or(false)
})
}
pub fn rebase_continue() -> Result<()> {
status_passthrough(&["rebase", "--continue"]).context("failed to continue rebase")
}
pub fn rebase_abort() -> Result<()> {
status(&["rebase", "--abort"]).context("failed to abort rebase")
}
pub fn cherry_pick(commit: &str) -> Result<()> {
status(&["cherry-pick", commit]).with_context(|| format!("failed to cherry-pick {commit}"))
}
pub fn fetch_tracking(remote: &str, branches: &[String]) -> Result<()> {
let present = remote_branches_present(remote, branches)?;
if present.is_empty() {
return Ok(());
}
let mut args = vec!["fetch", remote];
args.extend(present.iter().map(String::as_str));
status(&args).with_context(|| format!("failed to fetch branches from {remote}"))
}
pub(crate) fn remote_has_branch(remote: &str, branch: &str) -> Result<bool> {
Ok(!remote_branches_present(remote, std::slice::from_ref(&branch.to_owned()))?.is_empty())
}
fn remote_branches_present(remote: &str, branches: &[String]) -> Result<Vec<String>> {
if branches.is_empty() {
return Ok(Vec::new());
}
let mut args = vec!["ls-remote", "--heads", remote];
args.extend(branches.iter().map(String::as_str));
let listing =
output(&args).with_context(|| format!("failed to query {remote} for branch heads"))?;
let present: Vec<&str> = listing
.lines()
.filter_map(|line| line.split_once('\t'))
.filter_map(|(_, name)| name.strip_prefix("refs/heads/"))
.collect();
Ok(branches
.iter()
.filter(|branch| present.contains(&branch.as_str()))
.cloned()
.collect())
}
pub fn remote_only_commits(branch: &str, tracking: &str) -> Result<Vec<(String, String)>> {
let range = format!("{branch}...{tracking}");
let mut commits: Vec<(String, String)> = output(&[
"log",
"--cherry-pick",
"--right-only",
"--no-merges",
"--format=%h%x09%s",
&range,
])
.with_context(|| format!("failed to list remote-only commits in {range}"))?
.lines()
.filter_map(|line| {
line.split_once('\t')
.map(|(sha, subject)| (sha.to_owned(), subject.to_owned()))
})
.collect();
commits.reverse();
Ok(commits)
}
pub fn merge_adds_nothing(one: &str, other: &str) -> Result<bool> {
let Some(merged) = output_codes(
&["merge-tree", "--write-tree", one, other],
&[1],
"git merge-tree --write-tree",
)?
else {
return Ok(false);
};
let ours = output(&["rev-parse", &format!("{one}^{{tree}}")])
.with_context(|| format!("failed to read the tree of {one}"))?;
Ok(merged.lines().next().unwrap_or_default().trim() == ours.trim())
}
pub fn config_get(key: &str) -> Result<Option<String>> {
output_codes(&["config", "--get", key], &[1], "git config --get")
}
pub fn config_get_bool(key: &str) -> Result<Option<bool>> {
let Some(value) = output_codes(
&["config", "--type=bool", "--get", key],
&[1],
"git config --type=bool --get",
)?
else {
return Ok(None);
};
match value.as_str() {
"true" => Ok(Some(true)),
"false" => Ok(Some(false)),
_ => bail!("git config {key} is not a boolean: {value}"),
}
}
pub fn config_get_regexp(pattern: &str) -> Result<Vec<(String, String)>> {
let Some(text) = output_codes(
&["config", "--get-regexp", pattern],
&[1],
"git config --get-regexp",
)?
else {
return Ok(Vec::new());
};
Ok(text
.lines()
.filter_map(|line| {
line.split_once(' ')
.map(|(key, value)| (key.to_owned(), value.to_owned()))
})
.collect())
}
pub fn config_set(key: &str, value: &str) -> Result<()> {
status(&["config", key, value]).with_context(|| format!("failed to set git config {key}"))
}
pub fn config_unset(key: &str) -> Result<()> {
output_codes(&["config", "--unset", key], &[5], "git config --unset").map(|_| ())
}
fn output_codes(args: &[&str], ok_empty: &[i32], label: &str) -> Result<Option<String>> {
let output = Command::new("git")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.context("failed to run git")?;
match output.status.code() {
Some(0) => Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_owned(),
)),
Some(code) if ok_empty.contains(&code) => Ok(None),
_ => Err(command_error(label, &output.stderr)),
}
}
fn output(args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.context("failed to run git")?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
} else {
Err(command_error("git", &output.stderr))
}
}
fn output_with_stdin(args: &[&str], input: &str) -> Result<String> {
let mut child = Command::new("git")
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to run git")?;
{
let mut stdin = child.stdin.take().context("git has no stdin")?;
stdin
.write_all(input.as_bytes())
.context("failed to write to git")?;
}
let output = child.wait_with_output().context("failed to run git")?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
} else {
Err(command_error("git", &output.stderr))
}
}
fn status(args: &[&str]) -> Result<()> {
if verbose() {
return status_passthrough(args);
}
let output = Command::new("git")
.args(args)
.output()
.context("failed to run git")?;
if output.status.success() {
Ok(())
} else {
let _ = std::io::stdout().write_all(&output.stdout);
let _ = std::io::stderr().write_all(&output.stderr);
bail!("git exited with status {}", output.status)
}
}
fn status_passthrough(args: &[&str]) -> Result<()> {
let status = Command::new("git")
.args(args)
.status()
.context("failed to run git")?;
if status.success() {
Ok(())
} else {
bail!("git exited with status {status}")
}
}
fn command_error(command: &str, stderr: &[u8]) -> anyhow::Error {
let stderr = String::from_utf8_lossy(stderr).trim().to_owned();
if stderr.is_empty() {
anyhow!("{command} failed")
} else {
anyhow!("{command} failed: {stderr}")
}
}
#[cfg(test)]
mod tests {
use super::*;
const PORCELAIN: &str = "\
worktree /repo
HEAD f7cff917cf874d0c6ff3108260fda91ac3271baf
branch refs/heads/feat/b
worktree /repo/../wt-a
HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
branch refs/heads/feat/a
worktree /repo/../wt-detached
HEAD 25fb6254b4b1cd5cbe2b0d4b1f5b1cf6e7d8a9b0
detached
";
#[test]
fn worktree_parsing_keeps_branches_and_drops_detached_ones() {
let held = parse_worktree_branches(PORCELAIN, None);
assert_eq!(
held,
vec![
("feat/b".to_owned(), std::path::PathBuf::from("/repo")),
(
"feat/a".to_owned(),
std::path::PathBuf::from("/repo/../wt-a")
),
]
);
}
#[test]
fn worktree_parsing_excludes_the_worktree_we_are_standing_in() {
let held = parse_worktree_branches(PORCELAIN, Some(std::path::Path::new("/repo")));
assert_eq!(
held,
vec![(
"feat/a".to_owned(),
std::path::PathBuf::from("/repo/../wt-a")
)]
);
}
#[test]
fn a_bare_record_does_not_lend_its_path_to_the_next_branch() {
let porcelain = "\
worktree /repo/.bare
bare
worktree /repo/wt-a
HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
branch refs/heads/feat/a
";
assert_eq!(
parse_worktree_branches(porcelain, None),
vec![("feat/a".to_owned(), std::path::PathBuf::from("/repo/wt-a"))]
);
}
#[test]
fn branch_names_containing_slashes_survive_the_refs_heads_strip() {
let porcelain = "\
worktree /repo/wt
HEAD 0700673acebfe459d480fa3bd616b2ecf6249fe1
branch refs/heads/feat/deep/nested/name
";
assert_eq!(
parse_worktree_branches(porcelain, None)
.first()
.map(|(branch, _)| branch.as_str()),
Some("feat/deep/nested/name")
);
}
#[test]
fn empty_porcelain_holds_nothing() {
assert!(parse_worktree_branches("", None).is_empty());
}
#[test]
fn a_collision_message_quotes_the_path_it_suggests_pasting() {
let message = collision_message("feat/a", "../my worktree", false);
assert!(
message.contains(r#"`cd "../my worktree"`"#),
"cd suggestion is not pasteable: {message}"
);
assert!(
message.contains(r#"`git worktree remove "../my worktree"`"#),
"remove suggestion is not pasteable: {message}"
);
assert!(
message.contains(r#"`git -C "../my worktree" checkout --detach`"#),
"detach suggestion is not pasteable: {message}"
);
}
#[test]
fn a_collision_with_the_main_worktree_never_suggests_removing_it() {
let message = collision_message("feat/a", "../product", true);
assert!(
!message.contains("git worktree remove"),
"the main worktree cannot be removed: {message}"
);
assert!(
message.contains(r#"`git -C "../product" checkout --detach`"#),
"no workable way to free the branch: {message}"
);
}
#[test]
fn the_main_worktree_is_the_first_record_listed() {
let porcelain = "\
worktree /repo/product
HEAD 1111111111111111111111111111111111111111
branch refs/heads/feat/b
worktree /repo/product-worktrees/feat/a
HEAD 2222222222222222222222222222222222222222
branch refs/heads/feat/a
";
assert_eq!(
parse_main_worktree(porcelain),
Some(std::path::PathBuf::from("/repo/product"))
);
}
#[test]
fn no_listing_names_no_main_worktree() {
assert_eq!(parse_main_worktree(""), None);
}
#[test]
fn one_worktree_holding_three_branches_is_freed_once() {
let held = [
std::path::Path::new("../wt-a"),
std::path::Path::new("../wt-a"),
std::path::Path::new("../wt-b"),
];
assert_eq!(
distinct_paths(held),
vec![
std::path::PathBuf::from("../wt-a"),
std::path::PathBuf::from("../wt-b")
]
);
}
#[test]
fn a_collision_message_names_the_branch_and_where_it_lives() {
let message = collision_message("feat/a", "../wt-a", false);
assert!(message.starts_with("feat/a is checked out in the worktree at ../wt-a"));
}
#[test]
fn a_merge_queue_rejection_is_downgraded_to_the_queued_refs() {
let stderr = "\
remote: error: GH006: Protected branch update failed for refs/heads/feat/tf-deploy.
remote: - A pull request for this branch has been added to a merge queue. Branches that
remote: are queued for merging cannot be updated. To modify this branch, dequeue the
remote: associated pull request.
To github.com:higharc/product
+ 016bb37...3a94024 feat/spa-env -> feat/spa-env (forced update)
! [remote rejected] feat/tf-deploy -> feat/tf-deploy (protected branch hook declined)
error: failed to push some refs to 'github.com:higharc/product'";
assert_eq!(
merge_queue_rejection(stderr),
Some(vec!["feat/tf-deploy".to_owned()])
);
}
#[test]
fn a_stale_lease_rejection_is_not_swallowed_even_with_a_queue_mention() {
let stderr = "\
remote: GitHub found 270 vulnerabilities ... merge queue notes ...
! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
error: failed to push some refs";
assert_eq!(merge_queue_rejection(stderr), None);
}
#[test]
fn no_queue_mention_is_not_a_queue_rejection() {
let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
assert_eq!(merge_queue_rejection(stderr), None);
}
#[test]
fn landed_branches_drops_only_the_held_ones() {
let attempted = [
"feat/a".to_owned(),
"feat/b".to_owned(),
"feat/c".to_owned(),
];
assert_eq!(
landed_branches(&attempted, &["feat/b".to_owned()]),
vec!["feat/a".to_owned(), "feat/c".to_owned()]
);
assert_eq!(landed_branches(&attempted, &[]), attempted.to_vec());
assert!(landed_branches(&attempted, &attempted).is_empty());
}
#[test]
fn a_stale_lease_push_names_the_rejected_branch() {
let stderr = "\
To github.com:higharc/product
3a94024..d63a2b2 feat/spa-env -> feat/spa-env
! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
error: failed to push some refs to 'github.com:higharc/product'";
assert_eq!(
stale_rejection(stderr),
Some(vec!["feat/tf-deploy".to_owned()])
);
}
#[test]
fn a_non_fast_forward_push_is_treated_as_stale() {
let stderr = " ! [rejected] feat/x -> feat/x (non-fast-forward)";
assert_eq!(stale_rejection(stderr), Some(vec!["feat/x".to_owned()]));
}
#[test]
fn an_unrelated_push_failure_is_not_classified_as_stale() {
let stderr = " ! [remote rejected] feat/x -> feat/x (permission denied)";
assert_eq!(stale_rejection(stderr), None);
assert_eq!(stale_rejection("fatal: could not read from remote"), None);
}
#[test]
fn a_mixed_stale_and_non_stale_rejection_is_not_classified_as_stale() {
let stderr = "\
! [rejected] feat/tf-deploy -> feat/tf-deploy (stale info)
! [remote rejected] feat/locked -> feat/locked (permission denied)
error: failed to push some refs";
assert_eq!(stale_rejection(stderr), None);
}
#[test]
fn help_mentions_update_refs_matches_pre_2_43_spelling() {
assert!(help_mentions_update_refs(
" --update-refs update branches that point to commits that are being rebased"
));
}
#[test]
fn help_mentions_update_refs_matches_negatable_spelling() {
assert!(help_mentions_update_refs(
" --[no-]update-refs update branches that point to commits that are being rebased"
));
}
#[test]
fn help_mentions_update_refs_rejects_help_without_the_option() {
assert!(!help_mentions_update_refs(
" --[no-]autosquash move commits that begin with squash!/fixup!"
));
}
#[test]
fn detection_agrees_with_the_real_git_on_this_machine() {
let probe = Command::new("git")
.args(["rebase", "--update-refs", "-h"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("run git rebase probe");
let probe_text = format!(
"{}{}",
String::from_utf8_lossy(&probe.stdout),
String::from_utf8_lossy(&probe.stderr)
);
let real_support = !probe_text.contains("unknown option");
assert_eq!(
supports_rebase_update_refs().expect("detect support"),
real_support
);
}
}