use anyhow::{Context, Result, anyhow, bail};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Staged,
Tracked,
}
#[derive(Debug)]
pub struct Changes {
pub stat: String,
pub diff: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Commit {
pub hash: String,
pub message: String,
}
#[derive(Debug)]
pub struct CommitPatch {
pub hash: String,
pub message: String,
pub patch: String,
}
pub struct Repo {
workdir: PathBuf,
}
impl Repo {
pub fn open(workdir: impl Into<PathBuf>) -> Result<Self> {
let repo = Self {
workdir: workdir.into(),
};
let inside = repo.git(&["rev-parse", "--is-inside-work-tree"]);
if !inside.is_ok_and(|out| out.trim() == "true") {
bail!("not inside a git work tree");
}
Ok(repo)
}
fn command(&self, args: &[&str]) -> Command {
let mut command = Command::new("git");
command.current_dir(&self.workdir).args(args);
command
}
fn git(&self, args: &[&str]) -> Result<String> {
let output = self
.command(args)
.output()
.context("failed to run git; is it installed?")?;
if !output.status.success() {
bail!(
"`git {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn has_diff(&self, args: &[&str]) -> Result<bool> {
let output = self
.command(&["diff", "--quiet"])
.args(args)
.output()
.context("failed to run git; is it installed?")?;
match output.status.code() {
Some(0) => Ok(false),
Some(1) => Ok(true),
_ => bail!(
"`git diff` failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
}
}
pub fn stage_all(&self) -> Result<()> {
self.git(&["add", "--all"]).map(drop)
}
pub fn changes(&self, exclude: &[String]) -> Result<Option<(Scope, Changes)>> {
let scope = if self.has_diff(&["--cached"])? {
Scope::Staged
} else if self.has_diff(&[])? {
Scope::Tracked
} else {
return Ok(None);
};
let range: &[&str] = match scope {
Scope::Staged => &["--cached"],
Scope::Tracked => &[],
};
Ok(Some((scope, self.diff(range, exclude)?)))
}
pub fn branch_changes(&self, base: &str, exclude: &[String]) -> Result<Changes> {
self.diff(&[&format!("{base}...HEAD")], exclude)
}
fn diff(&self, range: &[&str], exclude: &[String]) -> Result<Changes> {
let base = ["diff", "--no-color", "--no-ext-diff"];
let stat = self.git(&[&base[..], range, &["--stat=120"]].concat())?;
let excludes = exclude_pathspecs(exclude);
let mut diff_args = [&base[..], range, &["--irreversible-delete", "--", ":/"]].concat();
diff_args.extend(excludes.iter().map(String::as_str));
let diff = self.git(&diff_args)?;
Ok(Changes { stat, diff })
}
pub fn commit_patches(&self, range: &[&str], exclude: &[String]) -> Result<Vec<CommitPatch>> {
let excludes = exclude_pathspecs(exclude);
let mut args = vec![
"log",
"--patch",
"--no-color",
"--no-ext-diff",
"--irreversible-delete",
"--format=%x1e%h%x1f%B%x1f",
];
args.extend_from_slice(range);
args.extend(["--", ":/"]);
args.extend(excludes.iter().map(String::as_str));
let log = self.git(&args)?;
let commits = log.split('\x1e').filter_map(|record| {
let mut parts = record.splitn(3, '\x1f');
let hash = parts.next()?.trim();
(!hash.is_empty()).then(|| CommitPatch {
hash: hash.to_owned(),
message: parts.next().unwrap_or_default().trim().to_owned(),
patch: parts.next().unwrap_or_default().to_owned(),
})
});
Ok(commits.collect())
}
pub fn outgoing_patches(&self, exclude: &[String]) -> Result<Vec<CommitPatch>> {
self.commit_patches(&["HEAD", "--not", "--remotes"], exclude)
}
pub fn commits_since(&self, base: &str) -> Result<Vec<Commit>> {
let log = self.git(&[
"log",
"--reverse",
"--no-merges",
"--format=%x1e%h%n%s%n%b",
&format!("{base}..HEAD"),
])?;
let commits = log.split('\x1e').filter_map(|record| {
let mut lines = record.trim().lines();
let hash = lines.next().filter(|hash| !hash.is_empty())?;
let mut message = format!("- {}", lines.next().unwrap_or_default());
for line in lines {
message.push('\n');
if !line.trim().is_empty() {
message.push_str(" ");
message.push_str(line.trim_end());
}
}
Some(Commit {
hash: hash.to_owned(),
message: message.trim_end().to_owned(),
})
});
Ok(commits.collect())
}
pub fn current_branch(&self) -> Result<String> {
let name = self
.git(&["symbolic-ref", "--quiet", "--short", "HEAD"])
.map_err(|_| anyhow!("HEAD is detached; check out a branch first"))?;
Ok(name.trim().to_owned())
}
pub fn remote(&self) -> Result<String> {
let branch = self.current_branch()?;
match self.git(&["config", &format!("branch.{branch}.remote")]) {
Ok(remote) if remote.trim() != "." => Ok(remote.trim().to_owned()),
_ => self.push_remote(),
}
}
pub fn fetch_branch(&self, remote: &str, branch: &str) -> Result<String> {
let tracking = format!("{remote}/{branch}");
let refspec = format!("+refs/heads/{branch}:refs/remotes/{tracking}");
let status = self
.command(&["fetch", "--quiet", remote, &refspec])
.status()
.context("failed to run git fetch")?;
if !status.success() {
bail!("`git fetch {remote} {branch}` failed ({status})");
}
Ok(tracking)
}
pub fn has_unpushed_commits(&self) -> Result<bool> {
if !self.has_upstream() {
return Ok(true);
}
let ahead = self.git(&["rev-list", "--max-count=1", "@{upstream}..HEAD"])?;
Ok(!ahead.trim().is_empty())
}
pub fn has_uncommitted_changes(&self) -> Result<bool> {
Ok(self.has_diff(&["--cached"])? || self.has_diff(&[])?)
}
pub fn workdir(&self) -> &Path {
&self.workdir
}
pub fn untracked_count(&self) -> Result<usize> {
let status = self.git(&["status", "--porcelain"])?;
Ok(status.lines().filter(|line| line.starts_with("??")).count())
}
pub fn git_path(&self, name: &str) -> Result<PathBuf> {
let path = self.git(&["rev-parse", "--git-path", name])?;
Ok(self.workdir.join(path.trim_end_matches(['\n', '\r'])))
}
pub fn commit(&self, message: &str, scope: Scope, extra_args: &[String]) -> Result<()> {
let path = self.git_path("GIT_AGENT_MSG")?;
fs::write(&path, format!("{message}\n"))
.with_context(|| format!("failed to write {}", path.display()))?;
let mut command = self.command(&["commit", "--file"]);
command.arg(&path);
if scope == Scope::Tracked {
command.arg("--all");
}
let status = command
.args(extra_args)
.status()
.context("failed to run git commit")?;
if !status.success() {
bail!(
"git commit failed; the message was saved, reuse it with:\n git commit --file {}",
path.display()
);
}
let _ = fs::remove_file(&path);
Ok(())
}
pub fn push(&self) -> Result<()> {
let mut command = self.command(&["push"]);
if !self.has_upstream() {
let remote = self.push_remote()?;
command.args(["--set-upstream", remote.as_str(), "HEAD"]);
}
let status = command.status().context("failed to run git push")?;
if !status.success() {
bail!("`git push` failed ({status})");
}
Ok(())
}
pub fn incoming_commits(&self) -> Result<Vec<String>> {
if !self.has_upstream() {
return Ok(Vec::new());
}
let status = self
.command(&["fetch", "--quiet"])
.status()
.context("failed to run git fetch")?;
if !status.success() {
bail!("`git fetch` failed ({status})");
}
let log = self.git(&["log", "--format=%h %s", "HEAD..@{upstream}"])?;
Ok(log.lines().map(str::to_owned).collect())
}
pub fn rebase_onto_upstream(&self) -> Result<()> {
let output = self
.command(&["rebase", "--autostash", "@{upstream}"])
.output()
.context("failed to run git rebase")?;
if output.status.success() {
return Ok(());
}
let _ = self.command(&["rebase", "--abort"]).output();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let conflicts: Vec<&str> = stdout
.lines()
.chain(stderr.lines())
.filter(|line| line.starts_with("CONFLICT"))
.collect();
if conflicts.is_empty() {
bail!("`git rebase` failed: {}", stderr.trim());
}
bail!(
"your changes conflict with the new remote commits, so the rebase was cancelled:\n {}\n\
Run `git pull --rebase`, resolve the conflicts, then `git push`",
conflicts.join("\n ")
);
}
fn has_upstream(&self) -> bool {
self.git(&[
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
])
.is_ok()
}
fn push_remote(&self) -> Result<String> {
if let Ok(remote) = self.git(&["config", "remote.pushDefault"]) {
return Ok(remote.trim().to_owned());
}
let remotes = self.git(&["remote"])?;
let remotes: Vec<&str> = remotes.lines().collect();
match remotes.as_slice() {
[] => bail!(
"there is no remote to push to; add one with `git remote add origin <url>`, \
or create a GitHub repository with `gh repo create --source . --push`"
),
[remote] => Ok(remote.to_string()),
_ if remotes.contains(&"origin") => Ok("origin".to_owned()),
_ => bail!(
"the branch has no upstream and there are several remotes; \
push it once with `git push --set-upstream <remote> HEAD`"
),
}
}
}
pub fn edit_file(path: &Path) -> Result<()> {
let output = Command::new("git")
.args(["var", "GIT_EDITOR"])
.output()
.context("failed to run git; is it installed?")?;
if !output.status.success() {
bail!(
"`git var GIT_EDITOR` failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let editor = String::from_utf8_lossy(&output.stdout).trim().to_owned();
let status = Command::new("sh")
.arg("-c")
.arg(format!("{editor} \"$@\""))
.arg(&editor)
.arg(path)
.status()
.with_context(|| format!("failed to start editor `{editor}`"))?;
if !status.success() {
bail!("editor `{editor}` exited with {status}");
}
Ok(())
}
fn exclude_pathspecs(patterns: &[String]) -> Vec<String> {
patterns
.iter()
.map(|pattern| {
if pattern.contains('/') {
format!(":(top,exclude,glob){pattern}")
} else {
format!(":(top,exclude,glob)**/{pattern}")
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn run(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn isolate(dir: &Path) {
run(dir, &["config", "user.name", "Test"]);
run(dir, &["config", "user.email", "test@example.com"]);
run(dir, &["config", "commit.gpgsign", "false"]);
run(dir, &["config", "core.hooksPath", "no-hooks"]);
}
fn setup() -> (TempDir, Repo) {
let dir = TempDir::new().unwrap();
run(dir.path(), &["init", "-q", "--initial-branch", "main"]);
isolate(dir.path());
fs::write(dir.path().join("lib.rs"), "fn a() {}\n").unwrap();
run(dir.path(), &["add", "lib.rs"]);
run(dir.path(), &["commit", "-q", "-m", "init"]);
let repo = Repo::open(dir.path()).unwrap();
(dir, repo)
}
fn add_remote(repo: &Repo) -> TempDir {
let remote = TempDir::new().unwrap();
run(
remote.path(),
&["init", "--bare", "-q", "--initial-branch", "main"],
);
repo.git(&["remote", "add", "origin", remote.path().to_str().unwrap()])
.unwrap();
repo.push().unwrap();
remote
}
fn commit_elsewhere(remote: &TempDir, file: &str, content: &str, subject: &str) {
let clone = TempDir::new().unwrap();
run(
clone.path(),
&["clone", "-q", remote.path().to_str().unwrap(), "."],
);
isolate(clone.path());
fs::write(clone.path().join(file), content).unwrap();
run(clone.path(), &["add", "--all"]);
run(clone.path(), &["commit", "-q", "-m", subject]);
run(clone.path(), &["push", "-q"]);
}
fn log(repo: &Repo) -> String {
repo.git(&["log", "--format=%B", "-1"]).unwrap()
}
#[test]
fn rejects_non_repository() {
let dir = TempDir::new().unwrap();
assert!(Repo::open(dir.path()).is_err());
}
#[test]
fn no_changes() {
let (_dir, repo) = setup();
assert!(repo.changes(&[]).unwrap().is_none());
}
#[test]
fn unstaged_changes_are_committed_with_all() {
let (dir, repo) = setup();
fs::write(dir.path().join("lib.rs"), "fn b() {}\n").unwrap();
fs::write(dir.path().join("new.rs"), "fn c() {}\n").unwrap();
let (scope, changes) = repo.changes(&[]).unwrap().unwrap();
assert_eq!(scope, Scope::Tracked);
assert!(changes.diff.contains("+fn b() {}"));
assert_eq!(repo.untracked_count().unwrap(), 1);
repo.commit("fix: rename a to b", scope, &[]).unwrap();
assert_eq!(log(&repo).trim(), "fix: rename a to b");
assert!(repo.changes(&[]).unwrap().is_none());
assert!(!repo.git_path("GIT_AGENT_MSG").unwrap().exists());
}
#[test]
fn staged_changes_take_priority() {
let (dir, repo) = setup();
fs::write(dir.path().join("lib.rs"), "fn b() {}\n").unwrap();
fs::write(dir.path().join("staged.rs"), "fn s() {}\n").unwrap();
repo.git(&["add", "staged.rs"]).unwrap();
let (scope, changes) = repo.changes(&[]).unwrap().unwrap();
assert_eq!(scope, Scope::Staged);
assert!(changes.diff.contains("staged.rs"));
assert!(!changes.diff.contains("lib.rs"));
}
#[test]
fn excluded_files_are_listed_but_not_diffed() {
let (dir, repo) = setup();
fs::create_dir(dir.path().join("sub")).unwrap();
fs::write(dir.path().join("sub/Cargo.lock"), "lock\n").unwrap();
fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
repo.stage_all().unwrap();
let (_, changes) = repo.changes(&["Cargo.lock".into()]).unwrap().unwrap();
assert!(changes.stat.contains("sub/Cargo.lock"));
assert!(!changes.diff.contains("Cargo.lock"));
assert!(changes.diff.contains("main.rs"));
}
#[test]
fn deleted_files_show_only_their_name() {
let (dir, repo) = setup();
fs::remove_file(dir.path().join("lib.rs")).unwrap();
let (_, changes) = repo.changes(&[]).unwrap().unwrap();
assert!(changes.diff.contains("deleted file"));
assert!(!changes.diff.contains("fn a()"));
}
#[test]
fn push_sets_upstream_then_reuses_it() {
let (dir, repo) = setup();
let remote = add_remote(&repo);
assert!(repo.has_upstream());
fs::write(dir.path().join("lib.rs"), "fn b() {}\n").unwrap();
repo.commit("feat: second", Scope::Tracked, &[]).unwrap();
assert!(repo.incoming_commits().unwrap().is_empty());
repo.push().unwrap();
let pushed = run(remote.path(), &["log", "--format=%s", "-1", "main"]);
assert_eq!(pushed.trim(), "feat: second");
}
#[test]
fn push_without_remote_explains_how_to_add_one() {
let (_dir, repo) = setup();
assert!(repo.incoming_commits().unwrap().is_empty());
let err = repo.push().unwrap_err();
assert!(err.to_string().contains("gh repo create"));
}
#[test]
fn rebases_onto_remote_commits_keeping_uncommitted_changes() {
let (dir, repo) = setup();
let remote = add_remote(&repo);
commit_elsewhere(&remote, "LICENSE", "MIT\n", "docs: add license");
fs::write(dir.path().join("lib.rs"), "fn b() {}\n").unwrap();
repo.commit("feat: local", Scope::Tracked, &[]).unwrap();
fs::write(dir.path().join("lib.rs"), "fn uncommitted() {}\n").unwrap();
let incoming = repo.incoming_commits().unwrap();
assert_eq!(incoming.len(), 1);
assert!(incoming[0].ends_with(" docs: add license"));
repo.rebase_onto_upstream().unwrap();
repo.push().unwrap();
let history = run(remote.path(), &["log", "--format=%s", "main"]);
assert_eq!(history, "feat: local\ndocs: add license\ninit\n");
let lib = fs::read_to_string(dir.path().join("lib.rs")).unwrap();
assert_eq!(lib, "fn uncommitted() {}\n");
}
#[test]
fn conflicting_rebase_is_cancelled() {
let (dir, repo) = setup();
let remote = add_remote(&repo);
commit_elsewhere(&remote, "lib.rs", "fn remote() {}\n", "fix: remote");
fs::write(dir.path().join("lib.rs"), "fn local() {}\n").unwrap();
repo.commit("fix: local", Scope::Tracked, &[]).unwrap();
let before = repo.git(&["rev-parse", "HEAD"]).unwrap();
assert_eq!(repo.incoming_commits().unwrap().len(), 1);
let err = repo.rebase_onto_upstream().unwrap_err();
assert!(
err.to_string().contains("Merge conflict in lib.rs"),
"{err}"
);
assert_eq!(repo.git(&["rev-parse", "HEAD"]).unwrap(), before);
assert!(!repo.git_path("rebase-merge").unwrap().exists());
assert!(repo.changes(&[]).unwrap().is_none());
}
#[test]
fn describes_a_branch_since_it_left_the_remote_base() {
let (dir, repo) = setup();
let remote = add_remote(&repo);
run(dir.path(), &["switch", "-q", "-c", "feature"]);
fs::write(dir.path().join("lib.rs"), "fn b() {}\n").unwrap();
repo.commit(
"feat: add b\n\nb replaces a.\n\nIt is faster.",
Scope::Tracked,
&[],
)
.unwrap();
fs::write(dir.path().join("new.rs"), "fn c() {}\n").unwrap();
repo.stage_all().unwrap();
repo.commit("test: cover c", Scope::Staged, &[]).unwrap();
commit_elsewhere(&remote, "LICENSE", "MIT\n", "docs: add license");
assert_eq!(repo.current_branch().unwrap(), "feature");
assert_eq!(repo.remote().unwrap(), "origin");
let base = repo.fetch_branch("origin", "main").unwrap();
assert_eq!(base, "origin/main");
let commits = repo.commits_since(&base).unwrap();
let messages: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
assert_eq!(
messages,
[
"- feat: add b\n b replaces a.\n\n It is faster.",
"- test: cover c"
]
);
assert!(commits.iter().all(|c| c.hash.len() >= 7));
let changes = repo.branch_changes(&base, &[]).unwrap();
assert!(changes.diff.contains("+fn b() {}") && changes.diff.contains("new.rs"));
assert!(!changes.diff.contains("LICENSE"));
assert!(changes.stat.contains("2 files changed"));
let patches = repo
.commit_patches(&[&format!("{base}..HEAD")], &[])
.unwrap();
let summary: Vec<_> = patches
.iter()
.map(|commit| (commit.message.as_str(), commit.patch.contains("+fn c() {}")))
.collect();
assert_eq!(
summary,
[
("test: cover c", true),
("feat: add b\n\nb replaces a.\n\nIt is faster.", false)
]
);
assert!(patches[1].patch.contains("diff --git a/lib.rs b/lib.rs"));
assert_eq!(repo.outgoing_patches(&[]).unwrap().len(), 2);
assert!(repo.has_unpushed_commits().unwrap());
repo.push().unwrap();
assert!(!repo.has_unpushed_commits().unwrap());
assert!(repo.outgoing_patches(&[]).unwrap().is_empty());
assert!(!repo.has_uncommitted_changes().unwrap());
fs::write(dir.path().join("lib.rs"), "fn d() {}\n").unwrap();
assert!(repo.has_uncommitted_changes().unwrap());
}
#[test]
fn detached_head_has_no_branch() {
let (dir, repo) = setup();
run(dir.path(), &["switch", "-q", "--detach"]);
let err = repo.current_branch().unwrap_err();
assert!(err.to_string().contains("detached"));
}
#[test]
fn failed_commit_keeps_message() {
let (dir, repo) = setup();
fs::write(dir.path().join("lib.rs"), "fn b() {}\n").unwrap();
let err = repo
.commit("feat: x", Scope::Tracked, &["--no-such-flag".into()])
.unwrap_err();
let saved = repo.git_path("GIT_AGENT_MSG").unwrap();
assert!(err.to_string().contains("GIT_AGENT_MSG"));
assert_eq!(fs::read_to_string(saved).unwrap(), "feat: x\n");
}
}