use camino::Utf8Path;
use tokio::process::Command;
use crate::error::{Error, Result};
fn git_in(dir: &Utf8Path) -> Command {
let mut cmd = Command::new("git");
cmd.current_dir(dir.as_std_path())
.env("LC_ALL", "C")
.env("LANG", "C");
cmd
}
fn is_kata_owned(path: &str) -> bool {
if path == ".kata/applied.toml" {
return true;
}
if let Some(rest) = path.strip_prefix(".kata/") {
if let Some(stripped) = rest.strip_suffix(".toml") {
if stripped == "vars" {
return true;
}
if let Some(layer) = stripped.strip_prefix("vars.") {
return !layer.is_empty() && !layer.contains('/');
}
}
}
false
}
pub async fn pull_ff(dir: &Utf8Path) -> Result<Option<()>> {
let output = git_in(dir)
.args(["pull", "--ff-only"])
.output()
.await
.map_err(|e| Error::Git(format!("spawn `git pull` in {dir}: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if stderr.contains("not a git repository") {
return Ok(None);
}
if stderr.contains("no tracking information")
|| stderr.contains("No remote repository specified")
{
return Ok(None);
}
return Err(Error::Git(format!("git pull in {dir}: {}", stderr.trim())));
}
Ok(Some(()))
}
pub async fn commit_paths(dir: &Utf8Path, paths: &[String], msg: &str) -> Result<bool> {
if paths.is_empty() {
return Ok(false);
}
let mut add = git_in(dir);
add.args(["add", "-A", "--"]);
for p in paths {
add.arg(p);
}
let output = add
.output()
.await
.map_err(|e| Error::Git(format!("spawn `git add` in {dir}: {e}")))?;
if !output.status.success() {
return Err(Error::Git(format!(
"git add in {dir}: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let mut cached_cmd = git_in(dir);
cached_cmd.args(["diff", "--cached", "--quiet", "--"]);
for p in paths {
cached_cmd.arg(p);
}
let cached = cached_cmd
.status()
.await
.map_err(|e| Error::Git(format!("spawn `git diff --cached` in {dir}: {e}")))?;
if cached.success() {
return Ok(false);
}
let mut commit = git_in(dir);
commit.args(["commit", "-m", msg, "--only", "--"]);
for p in paths {
commit.arg(p);
}
let output = commit
.output()
.await
.map_err(|e| Error::Git(format!("spawn `git commit` in {dir}: {e}")))?;
if !output.status.success() {
return Err(Error::Git(format!(
"git commit in {dir}: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(true)
}
pub async fn push_current(dir: &Utf8Path) -> Result<bool> {
let upstream = git_in(dir)
.args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
.output()
.await
.map_err(|e| Error::Git(format!("spawn `git rev-parse @{{u}}` in {dir}: {e}")))?;
if !upstream.status.success() {
let stderr = String::from_utf8_lossy(&upstream.stderr);
let s = stderr.to_ascii_lowercase();
let benign = s.contains("no upstream configured")
|| s.contains("ambiguous argument '@{u}'")
|| s.contains("unknown revision");
if benign {
return Ok(false);
}
return Err(Error::Git(format!(
"git rev-parse @{{u}} in {dir}: {}",
stderr.trim()
)));
}
let output = git_in(dir)
.arg("push")
.output()
.await
.map_err(|e| Error::Git(format!("spawn `git push` in {dir}: {e}")))?;
if !output.status.success() {
return Err(Error::Git(format!(
"git push in {dir}: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(true)
}
pub async fn dirty_files(dir: &Utf8Path) -> Result<Option<Vec<String>>> {
let output = git_in(dir)
.args(["status", "--porcelain", "-z"])
.output()
.await
.map_err(|e| Error::Git(format!("spawn `git status` in {dir}: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if stderr.contains("not a git repository") {
return Ok(None);
}
return Err(Error::Git(format!(
"git status in {dir}: {}",
stderr.trim()
)));
}
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
Ok(Some(parse_porcelain_z(&stdout)))
}
pub(crate) fn parse_porcelain_z(porcelain: &str) -> Vec<String> {
let mut out = Vec::new();
let mut iter = porcelain.split('\0');
while let Some(entry) = iter.next() {
if entry.is_empty() {
continue;
}
if entry.len() < 4 {
continue;
}
let xy = &entry[..2];
let dst = &entry[3..];
let index_status = xy.chars().next().unwrap_or(' ');
if matches!(index_status, 'R' | 'C') {
let orig = iter.next().unwrap_or("");
if !(is_kata_owned(dst) && is_kata_owned(orig)) {
if !dst.is_empty() {
out.push(dst.replace('\\', "/"));
}
}
} else if !is_kata_owned(dst) {
out.push(dst.replace('\\', "/"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_modified_and_added_lines() {
let out = " M src/main.rs\0A README.md\0?? scratch.txt\0";
let mut dirty = parse_porcelain_z(out);
dirty.sort();
assert_eq!(
dirty,
vec![
"README.md".to_string(),
"scratch.txt".to_string(),
"src/main.rs".to_string()
],
);
}
#[test]
fn filters_kata_bookkeeping() {
let out = " M .kata/applied.toml\0 M .kata/vars.toml\0 M src/lib.rs\0";
let dirty = parse_porcelain_z(out);
assert_eq!(dirty, vec!["src/lib.rs".to_string()]);
}
#[test]
fn empty_porcelain_means_clean() {
assert!(parse_porcelain_z("").is_empty());
assert!(parse_porcelain_z("\0\0").is_empty());
}
#[test]
fn rename_lines_use_destination() {
let out = "R new/path.rs\0old/path.rs\0";
assert_eq!(parse_porcelain_z(out), vec!["new/path.rs".to_string()],);
}
#[test]
fn quoted_paths_are_no_longer_quoted_under_z() {
let out = " M file with spaces.rs\0";
assert_eq!(
parse_porcelain_z(out),
vec!["file with spaces.rs".to_string()],
);
}
#[test]
fn path_containing_arrow_is_not_split() {
let out = " M a -> b.txt\0";
assert_eq!(parse_porcelain_z(out), vec!["a -> b.txt".to_string()],);
}
#[test]
fn rename_into_kata_owned_from_user_path_still_surfaces() {
let out = "R .kata/vars.toml\0notes.md\0";
assert_eq!(
parse_porcelain_z(out),
vec![".kata/vars.toml".to_string()],
"rename whose source is user content must surface",
);
}
#[test]
fn rename_within_kata_owned_files_is_suppressed() {
let out = "R .kata/applied.toml\0.kata/vars.toml\0";
assert!(
parse_porcelain_z(out).is_empty(),
"rename between two kata-owned paths is routine and should be filtered",
);
}
#[test]
fn copy_status_consumes_two_paths() {
let out = "C dest.rs\0src.rs\0";
assert_eq!(parse_porcelain_z(out), vec!["dest.rs".to_string()]);
}
#[test]
fn filters_only_intended_kata_files() {
let out = " M .kata/applied.toml\0 M .kata/vars.toml\0 M .kata/vars.rust.toml\0 M .kata/scratch.md\0 M src/lib.rs\0";
let mut dirty = parse_porcelain_z(out);
dirty.sort();
assert_eq!(
dirty,
vec![".kata/scratch.md".to_string(), "src/lib.rs".to_string()],
);
}
#[test]
fn path_with_inner_whitespace_preserved_under_z() {
let out = " M leading.rs\0";
assert_eq!(parse_porcelain_z(out), vec![" leading.rs".to_string()],);
}
#[test]
fn malformed_short_record_is_dropped() {
let out = "XY\0 M\0\0";
assert!(parse_porcelain_z(out).is_empty());
}
}