cosmic-edit/src/git.rs

284 lines
8.1 KiB
Rust

//TODO: try to use gitoxide
use std::{
fs, io,
path::{Path, PathBuf},
};
use tokio::process::Command;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitDiff {
pub path: PathBuf,
pub staged: bool,
pub hunks: Vec<GitDiffHunk>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitDiffHunk {
pub old_range: patch::Range,
pub new_range: patch::Range,
pub lines: Vec<GitDiffLine>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GitDiffLine {
Context {
old_line: u64,
new_line: u64,
text: String,
},
Added {
new_line: u64,
text: String,
},
Deleted {
old_line: u64,
text: String,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitStatus {
pub path: PathBuf,
pub old_path: Option<PathBuf>,
pub staged: GitStatusKind,
pub unstaged: GitStatusKind,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitStatusKind {
Unmodified,
Modified,
FileTypeChanged,
Added,
Deleted,
Renamed,
Copied,
Updated,
Untracked,
SubmoduleModified,
}
impl TryFrom<char> for GitStatusKind {
type Error = char;
fn try_from(c: char) -> Result<Self, Self::Error> {
// https://git-scm.com/docs/git-status#_short_format
match c {
' ' => Ok(Self::Unmodified),
'M' => Ok(Self::Modified),
'T' => Ok(Self::FileTypeChanged),
'A' => Ok(Self::Added),
'D' => Ok(Self::Deleted),
'R' => Ok(Self::Renamed),
'C' => Ok(Self::Copied),
'U' => Ok(Self::Updated),
'?' => Ok(Self::Untracked),
'm' => Ok(Self::SubmoduleModified),
_ => Err(c),
}
}
}
pub struct GitRepository {
path: PathBuf,
}
impl GitRepository {
pub fn new<P: AsRef<Path>>(path: P) -> io::Result<Self> {
let path = path.as_ref();
if path.join(".git").exists() {
let path = fs::canonicalize(path)?;
Ok(Self { path })
} else {
Err(io::Error::new(
io::ErrorKind::NotFound,
format!("{:?} is not a git repository", path),
))
}
}
fn command(&self) -> Command {
let mut command = Command::new("git");
command.arg("-C").arg(&self.path);
command
}
async fn command_stdout(mut command: Command) -> io::Result<String> {
log::info!("{:?}", command);
let output = command.output().await?;
if output.status.success() {
String::from_utf8(output.stdout).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to parse git stdout: {}", err),
)
})
} else {
let mut msg = format!("git exited with {}", output.status);
for line in String::from_utf8_lossy(&output.stdout).lines() {
msg.push_str("\nstdout> ");
msg.push_str(line);
}
for line in String::from_utf8_lossy(&output.stderr).lines() {
msg.push_str("\nstderr> ");
msg.push_str(line);
}
Err(io::Error::new(io::ErrorKind::Other, msg))
}
}
pub async fn diff<P: AsRef<Path>>(&self, path: P, staged: bool) -> io::Result<GitDiff> {
let path = path.as_ref();
let mut command = self.command();
command.arg("diff");
if staged {
command.arg("--staged");
}
command.arg("--").arg(path);
let diff = Self::command_stdout(command).await?;
let patch = patch::Patch::from_single(&diff).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to parse diff: {}", err),
)
})?;
let mut hunks = Vec::with_capacity(patch.hunks.len());
for hunk in patch.hunks.iter() {
//TODO: validate range counts
let mut old_line = hunk.old_range.start;
let mut new_line = hunk.new_range.start;
let mut lines = Vec::with_capacity(hunk.lines.len());
for line in hunk.lines.iter() {
match line {
patch::Line::Context(text) => {
lines.push(GitDiffLine::Context {
old_line,
new_line,
text: text.to_string(),
});
old_line += 1;
new_line += 1;
}
patch::Line::Add(text) => {
lines.push(GitDiffLine::Added {
new_line,
text: text.to_string(),
});
new_line += 1;
}
patch::Line::Remove(text) => {
lines.push(GitDiffLine::Deleted {
old_line,
text: text.to_string(),
});
old_line += 1;
}
}
}
hunks.push(GitDiffHunk {
old_range: hunk.old_range.clone(),
new_range: hunk.new_range.clone(),
lines,
});
}
Ok(GitDiff {
path: path.to_path_buf(),
staged,
hunks,
})
}
pub async fn status(&self) -> io::Result<Vec<GitStatus>> {
let mut command = self.command();
command.arg("status").arg("-z");
let stdout = Self::command_stdout(command).await?;
let mut status = Vec::new();
let mut lines = stdout.split('\0');
while let Some(line) = lines.next() {
macro_rules! invalid_line {
() => {{
log::warn!("invalid git status line {:?}", line);
continue;
}};
}
if line.is_empty() {
// Ignore empty lines
continue;
}
let mut chars = line.chars();
// Get staged status
let staged = match chars.next() {
Some(some) => match GitStatusKind::try_from(some) {
Ok(ok) => ok,
Err(_) => invalid_line!(),
},
None => invalid_line!(),
};
// Get unstaged status
let unstaged = match chars.next() {
Some(some) => match GitStatusKind::try_from(some) {
Ok(ok) => ok,
Err(_) => invalid_line!(),
},
None => invalid_line!(),
};
// Skip space
match chars.next() {
Some(' ') => {}
_ => invalid_line!(),
}
// The rest of the chars are in the path
let relative_path: String = chars.collect();
let old_path = if staged == GitStatusKind::Renamed || unstaged == GitStatusKind::Renamed
{
match lines.next() {
Some(old_relative_path) => Some(self.path.join(old_relative_path)),
None => invalid_line!(),
}
} else {
None
};
status.push(GitStatus {
path: self.path.join(relative_path),
old_path,
staged,
unstaged,
})
}
Ok(status)
}
pub async fn stage<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
let path = path.as_ref();
let mut command = self.command();
command.arg("stage");
command.arg("--").arg(path);
Self::command_stdout(command).await?;
Ok(())
}
pub async fn unstage<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
let path = path.as_ref();
let mut command = self.command();
command.arg("restore");
command.arg("--staged");
command.arg("--").arg(path);
Self::command_stdout(command).await?;
Ok(())
}
}