use std::path::{Path, PathBuf};
use std::process::Command;
use crate::error::{Error, Result};
use crate::ui::Ui;
pub const GIT_BIN: &str = "git";
fn git(dir: &Path, args: &[&str]) -> Result<std::process::Output> {
let output = Command::new(GIT_BIN)
.arg("-C")
.arg(dir)
.args(args)
.output()?;
Ok(output)
}
pub fn repo_root(start: &Path) -> Result<PathBuf> {
let output = git(start, &["rev-parse", "--show-toplevel"])?;
if !output.status.success() {
return Err(Error::NotAGitRepo);
}
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() {
return Err(Error::NotAGitRepo);
}
Ok(PathBuf::from(path))
}
pub fn tracked_files(repo: &Path) -> Result<Vec<PathBuf>> {
let output = git(repo, &["ls-files"])?;
if !output.status.success() {
return Err(Error::ProcessFailed {
tool: GIT_BIN.to_string(),
code: output.status.code().unwrap_or(-1),
message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect())
}
pub fn is_tracked(repo: &Path, path: &Path) -> Result<bool> {
let path_str = path.to_string_lossy();
let output = git(repo, &["ls-files", "--error-unmatch", &path_str])?;
Ok(output.status.success())
}
pub fn is_ignored(repo: &Path, path: &Path) -> Result<bool> {
let path_str = path.to_string_lossy();
let output = git(repo, &["check-ignore", "--quiet", &path_str])?;
match output.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
other => Err(Error::ProcessFailed {
tool: GIT_BIN.to_string(),
code: other.unwrap_or(-1),
message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
}),
}
}
pub fn stage(repo: &Path, files: &[PathBuf]) -> Result<Vec<PathBuf>> {
let existing: Vec<PathBuf> = files.iter().filter(|path| path.exists()).cloned().collect();
if existing.is_empty() {
return Ok(existing);
}
let mut args: Vec<String> = vec!["add".to_string(), "--".to_string()];
args.extend(
existing
.iter()
.map(|path| path.to_string_lossy().into_owned()),
);
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let output = git(repo, &refs)?;
if !output.status.success() {
return Err(Error::ProcessFailed {
tool: GIT_BIN.to_string(),
code: output.status.code().unwrap_or(-1),
message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
Ok(existing)
}
pub fn current_branch(repo: &Path) -> Option<String> {
let output = git(repo, &["symbolic-ref", "--quiet", "--short", "HEAD"]).ok()?;
if !output.status.success() {
return None;
}
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch.is_empty() {
None
} else {
Some(branch)
}
}
pub fn stage_and_advise(
ui: &Ui,
repo: &Path,
files: &[PathBuf],
commit_subject: &str,
) -> Result<()> {
let staged = stage(repo, files)?;
ui.header("Staged for you (--git)");
if staged.is_empty() {
ui.warn("no changed files were found to stage");
return Ok(());
}
for path in &staged {
ui.success(format!("git add {}", relative_display(repo, path)));
}
ui.header("Next: commit and open a pull request");
ui.command(format!("git commit -m {}", quote(commit_subject)));
match current_branch(repo) {
Some(branch) => ui.command(format!("git push -u origin {branch}")),
None => ui.command("git push -u origin HEAD"),
}
ui.command("gh pr create --fill # or open a pull request on your Git host");
Ok(())
}
fn relative_display(repo: &Path, path: &Path) -> String {
path.strip_prefix(repo)
.unwrap_or(path)
.to_string_lossy()
.into_owned()
}
fn quote(subject: &str) -> String {
let escaped = subject.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
pub fn ensure_gitignored(repo: &Path, pattern: &str) -> Result<bool> {
let gitignore = repo.join(".gitignore");
let existing = match std::fs::read_to_string(&gitignore) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(err) => return Err(Error::Io(err)),
};
let already_present = existing
.lines()
.map(str::trim)
.any(|line| line == pattern.trim());
if already_present {
return Ok(false);
}
let mut updated = existing;
if !updated.is_empty() && !updated.ends_with('\n') {
updated.push('\n');
}
updated.push_str(pattern.trim_end());
updated.push('\n');
std::fs::write(&gitignore, updated)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_escapes_quotes_and_backslashes() {
assert_eq!(quote("simple"), "\"simple\"");
assert_eq!(quote(r#"a "b" \c"#), r#""a \"b\" \\c""#);
}
#[test]
fn relative_display_strips_the_repo_prefix() {
let repo = Path::new("/repo");
assert_eq!(
relative_display(repo, Path::new("/repo/.sops.yaml")),
".sops.yaml"
);
assert_eq!(relative_display(repo, Path::new("/other/x")), "/other/x");
}
}