Files
RuDS/crates/bds-core/src/engine/git.rs

1710 lines
54 KiB
Rust

use std::collections::{HashMap, HashSet};
use std::fmt;
use std::fs;
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use crate::db::DbConnection;
use crate::engine::{EngineError, EngineResult};
pub const LOCAL_TIMEOUT: Duration = Duration::from_secs(15);
pub const NETWORK_TIMEOUT: Duration = Duration::from_secs(120);
pub const FILE_HISTORY_LIMIT: usize = 50;
const GITIGNORE_LINES: &[&str] = &[
"/html/",
"/thumbnails/",
"/pagefind/",
"/.DS_Store",
"/node_modules/",
"/deps/",
"/_build/",
];
const LFS_PATTERNS: &[&str] = &[
"*.jpg", "*.jpeg", "*.png", "*.gif", "*.webp", "*.svg", "*.tif", "*.tiff", "*.bmp", "*.heic",
"*.heif",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitProvider {
GitHub,
GitLab,
GiteaForgejo,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncStatus {
LocalOnly,
RemoteOnly,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileStatusKind {
Added,
Modified,
Deleted,
Renamed,
Untracked,
}
impl FileStatusKind {
pub fn code(self) -> &'static str {
match self {
Self::Added => "A",
Self::Modified => "M",
Self::Deleted => "D",
Self::Renamed => "R",
Self::Untracked => "U",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitFileStatus {
pub path: String,
pub old_path: Option<String>,
pub kind: FileStatusKind,
pub staged: bool,
pub unstaged: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitCommit {
pub hash: String,
pub subject: Option<String>,
pub author: Option<String>,
pub date: Option<String>,
pub sync_status: SyncStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitRepository {
pub is_initialized: bool,
pub remote_url: Option<String>,
pub provider: Option<GitProvider>,
pub current_branch: Option<String>,
pub has_lfs: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitRemoteState {
pub local_branch: Option<String>,
pub upstream_branch: Option<String>,
pub has_upstream: bool,
pub ahead: usize,
pub behind: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitDiff {
pub staged: String,
pub unstaged: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitFileDiff {
pub file_path: String,
pub original: String,
pub modified: String,
pub patch: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitOperation {
Initialize,
Status,
Diff,
History,
Remote,
Fetch,
Pull,
Push,
Commit,
Lfs,
Reconcile,
}
impl fmt::Display for GitOperation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Initialize => "initialize",
Self::Status => "status",
Self::Diff => "diff",
Self::History => "history",
Self::Remote => "remote",
Self::Fetch => "fetch",
Self::Pull => "pull",
Self::Push => "push",
Self::Commit => "commit",
Self::Lfs => "LFS",
Self::Reconcile => "reconcile",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitPlatform {
MacOs,
Windows,
Linux,
}
impl fmt::Display for GitPlatform {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::MacOs => "macOS",
Self::Windows => "Windows",
Self::Linux => "Linux",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitError {
Io {
operation: GitOperation,
message: String,
},
Validation(String),
Failed {
operation: GitOperation,
output: String,
},
TimedOut {
operation: GitOperation,
timeout: Duration,
output: String,
},
Cancelled {
operation: GitOperation,
output: String,
},
Authentication {
operation: GitOperation,
provider: Option<GitProvider>,
platform: GitPlatform,
guidance: String,
},
}
impl fmt::Display for GitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { operation, message } => write!(f, "Git {operation} failed: {message}"),
Self::Validation(message) => f.write_str(message),
Self::Failed { operation, output } => {
write!(f, "Git {operation} failed: {output}")
}
Self::TimedOut {
operation, timeout, ..
} => write!(
f,
"Git {operation} timed out after {}ms",
timeout.as_millis()
),
Self::Cancelled { operation, .. } => write!(f, "Git {operation} was cancelled"),
Self::Authentication { guidance, .. } => f.write_str(guidance),
}
}
}
impl std::error::Error for GitError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitOutputStream {
Stdout,
Stderr,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitOutput {
pub stream: GitOutputStream,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetworkResult {
pub output: String,
}
#[derive(Debug, Clone)]
pub struct GitEngine {
repository_dir: PathBuf,
executable: PathBuf,
local_timeout: Duration,
network_timeout: Duration,
}
impl GitEngine {
pub fn new(repository_dir: impl Into<PathBuf>) -> Self {
Self {
repository_dir: repository_dir.into(),
executable: PathBuf::from("git"),
local_timeout: LOCAL_TIMEOUT,
network_timeout: NETWORK_TIMEOUT,
}
}
pub fn repository(&self) -> Result<GitRepository, GitError> {
if !self.repository_dir.join(".git").exists() {
return Ok(GitRepository {
is_initialized: false,
remote_url: None,
provider: None,
current_branch: None,
has_lfs: false,
});
}
let remote_url =
self.optional_output(&["remote", "get-url", "origin"], GitOperation::Remote)?;
Ok(GitRepository {
is_initialized: true,
provider: remote_url.as_deref().map(provider_from_url),
remote_url,
current_branch: self.current_branch()?,
has_lfs: self.has_lfs_tracking(),
})
}
pub fn initialize(&self) -> Result<GitRepository, GitError> {
fs::create_dir_all(&self.repository_dir)
.map_err(|error| self.io_error(GitOperation::Initialize, error))?;
self.run_local(&["init", "-b", "master"], GitOperation::Initialize)?;
self.run_local(&["lfs", "install", "--local"], GitOperation::Lfs)?;
let mut args = vec!["lfs", "track"];
args.extend(LFS_PATTERNS.iter().copied());
self.run_local(&args, GitOperation::Lfs)?;
append_missing_lines(&self.repository_dir.join(".gitignore"), GITIGNORE_LINES)
.map_err(|error| self.io_error(GitOperation::Initialize, error))?;
append_lfs_lines(&self.repository_dir.join(".gitattributes"))
.map_err(|error| self.io_error(GitOperation::Lfs, error))?;
self.repository()
}
pub fn status(&self) -> Result<Vec<GitFileStatus>, GitError> {
let output = self.run_local_bytes(
&["status", "--porcelain=v1", "-z", "--untracked-files=all"],
GitOperation::Status,
)?;
Ok(parse_status(&output))
}
pub fn diff(&self) -> Result<GitDiff, GitError> {
Ok(GitDiff {
staged: self.run_local(
&["diff", "--cached", "--no-ext-diff", "--no-color"],
GitOperation::Diff,
)?,
unstaged: self
.run_local(&["diff", "--no-ext-diff", "--no-color"], GitOperation::Diff)?,
})
}
pub fn file_diff(&self, file_path: &str) -> Result<GitFileDiff, GitError> {
validate_relative_path(file_path)?;
let revision = format!("HEAD:{file_path}");
let original = self
.optional_raw_output(&["show", &revision], GitOperation::Diff)?
.unwrap_or_default();
let modified = match fs::read(self.repository_dir.join(file_path)) {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(self.io_error(GitOperation::Diff, error)),
};
let mut patch = self
.optional_raw_output(
&[
"diff",
"HEAD",
"--no-ext-diff",
"--no-color",
"--",
file_path,
],
GitOperation::Diff,
)?
.unwrap_or_default();
if patch.is_empty() && original.is_empty() && !modified.is_empty() {
patch = added_file_patch(file_path, &modified);
}
Ok(GitFileDiff {
file_path: file_path.to_string(),
original,
modified,
patch,
})
}
pub fn commit_files(&self, hash: &str) -> Result<Vec<ChangedFile>, GitError> {
if !is_object_name(hash) {
return Err(GitError::Validation("invalid commit identifier".into()));
}
let output = self.run_local_bytes(
&[
"diff-tree",
"--root",
"--no-commit-id",
"--name-status",
"-r",
"-z",
"--find-renames",
hash,
],
GitOperation::Diff,
)?;
Ok(parse_changed_files(&output))
}
pub fn commit_file_diff(
&self,
hash: &str,
change: &ChangedFile,
) -> Result<GitFileDiff, GitError> {
if !is_object_name(hash) {
return Err(GitError::Validation("invalid commit identifier".into()));
}
validate_relative_path(&change.path)?;
if let Some(old_path) = &change.old_path {
validate_relative_path(old_path)?;
}
let old_path = change.old_path.as_deref().unwrap_or(&change.path);
let parent_revision = format!("{hash}^:{old_path}");
let revision = format!("{hash}:{}", change.path);
let original = if change.kind == FileStatusKind::Added {
String::new()
} else {
self.run_local(&["show", &parent_revision], GitOperation::Diff)?
};
let modified = if change.kind == FileStatusKind::Deleted {
String::new()
} else {
self.run_local(&["show", &revision], GitOperation::Diff)?
};
let patch = self.run_local(
&[
"show",
"--format=",
"--no-ext-diff",
"--no-color",
hash,
"--",
&change.path,
],
GitOperation::Diff,
)?;
Ok(GitFileDiff {
file_path: change.path.clone(),
original,
modified,
patch,
})
}
pub fn history(&self, branch: &str) -> Result<Vec<GitCommit>, GitError> {
if branch.trim().is_empty() {
return Ok(Vec::new());
}
let local = self.history_for_revision_optional(branch)?;
let upstream = self.upstream_branch()?;
let remote = match upstream {
Some(upstream) => self.history_for_revision_optional(&upstream)?,
None => Vec::new(),
};
let local_hashes = local
.iter()
.map(|commit| commit.hash.clone())
.collect::<HashSet<_>>();
let remote_hashes = remote
.iter()
.map(|commit| commit.hash.clone())
.collect::<HashSet<_>>();
let mut commits = local
.into_iter()
.map(|mut commit| {
commit.sync_status = if remote_hashes.contains(&commit.hash) {
SyncStatus::Both
} else {
SyncStatus::LocalOnly
};
commit
})
.collect::<Vec<_>>();
commits.extend(remote.into_iter().filter_map(|mut commit| {
(!local_hashes.contains(&commit.hash)).then(|| {
commit.sync_status = SyncStatus::RemoteOnly;
commit
})
}));
Ok(commits)
}
pub fn file_history(&self, file_path: &str) -> Result<Vec<GitCommit>, GitError> {
validate_relative_path(file_path)?;
let limit = FILE_HISTORY_LIMIT.to_string();
let output = self.optional_output(
&[
"log",
"--follow",
"--date=short",
"--format=%H%x1f%an%x1f%ad%x1f%s%x1e",
"-n",
&limit,
"--",
file_path,
],
GitOperation::History,
)?;
Ok(output.map_or_else(Vec::new, |output| parse_history(&output)))
}
pub fn commit_diff(&self, hash: &str) -> Result<String, GitError> {
if !is_object_name(hash) {
return Err(GitError::Validation("invalid commit identifier".into()));
}
self.run_local(
&[
"show",
"--format=fuller",
"--no-ext-diff",
"--no-color",
hash,
],
GitOperation::Diff,
)
}
pub fn remote_state(&self) -> Result<GitRemoteState, GitError> {
let local_branch = self.current_branch()?;
let upstream_branch = self.upstream_branch()?;
let Some(upstream) = upstream_branch.clone() else {
return Ok(GitRemoteState {
local_branch,
upstream_branch: None,
has_upstream: false,
ahead: 0,
behind: 0,
});
};
Ok(GitRemoteState {
local_branch,
upstream_branch,
has_upstream: true,
ahead: self.revision_count(&format!("{upstream}..HEAD"))?,
behind: self.revision_count(&format!("HEAD..{upstream}"))?,
})
}
pub fn set_remote(&self, remote_url: &str) -> Result<(), GitError> {
let remote_url = remote_url.trim();
if remote_url.is_empty() {
return Err(GitError::Validation("remote URL is required".into()));
}
let args = if self
.optional_output(&["remote", "get-url", "origin"], GitOperation::Remote)?
.is_some()
{
["remote", "set-url", "origin", remote_url]
} else {
["remote", "add", "origin", remote_url]
};
self.run_local(&args, GitOperation::Remote).map(|_| ())
}
pub fn commit_all(&self, message: &str) -> Result<NetworkResult, GitError> {
let message = message.trim();
if message.is_empty() {
return Err(GitError::Validation("commit message is required".into()));
}
self.run_local(&["add", "-A"], GitOperation::Commit)?;
let output = self.run_local(&["commit", "-m", message], GitOperation::Commit)?;
Ok(NetworkResult { output })
}
pub fn fetch(
&self,
is_cancelled: impl Fn() -> bool,
mut on_output: impl FnMut(GitOutput),
) -> Result<NetworkResult, GitError> {
self.run_network(
&["fetch", "--all", "--prune", "--progress"],
GitOperation::Fetch,
&is_cancelled,
&mut on_output,
)
}
pub fn pull(
&self,
is_cancelled: impl Fn() -> bool,
mut on_output: impl FnMut(GitOutput),
) -> Result<NetworkResult, GitError> {
self.run_network(
&["pull", "--ff-only", "--progress"],
GitOperation::Pull,
&is_cancelled,
&mut on_output,
)
}
pub fn push(
&self,
is_cancelled: impl Fn() -> bool,
mut on_output: impl FnMut(GitOutput),
) -> Result<NetworkResult, GitError> {
self.run_network(
&["push", "--progress"],
GitOperation::Push,
&is_cancelled,
&mut on_output,
)
}
pub fn prune_lfs_cache(&self, retain_recent_days: u32) -> Result<NetworkResult, GitError> {
let retention = format!("lfs.fetchrecentcommitsdays={retain_recent_days}");
let output = self.run_local(
&["-c", &retention, "lfs", "prune", "--recent"],
GitOperation::Lfs,
)?;
Ok(NetworkResult { output })
}
pub fn head(&self) -> Result<Option<String>, GitError> {
self.optional_output(&["rev-parse", "--verify", "HEAD"], GitOperation::History)
}
pub fn changed_files(
&self,
old_commit: &str,
new_commit: &str,
) -> Result<Vec<ChangedFile>, GitError> {
if !is_object_name(old_commit) || !is_object_name(new_commit) {
return Err(GitError::Validation("invalid commit identifier".into()));
}
let output = self.run_local_bytes(
&[
"diff",
"--name-status",
"-z",
"--find-renames",
old_commit,
new_commit,
],
GitOperation::Reconcile,
)?;
Ok(parse_changed_files(&output))
}
fn current_branch(&self) -> Result<Option<String>, GitError> {
self.optional_output(
&["symbolic-ref", "--quiet", "--short", "HEAD"],
GitOperation::History,
)
}
fn upstream_branch(&self) -> Result<Option<String>, GitError> {
self.optional_output(
&[
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
],
GitOperation::Remote,
)
}
fn revision_count(&self, range: &str) -> Result<usize, GitError> {
Ok(self
.optional_output(&["rev-list", "--count", range], GitOperation::History)?
.and_then(|value| value.parse().ok())
.unwrap_or(0))
}
fn history_for_revision_optional(&self, revision: &str) -> Result<Vec<GitCommit>, GitError> {
Ok(self
.optional_output(
&[
"log",
"--date=short",
"--format=%H%x1f%an%x1f%ad%x1f%s%x1e",
revision,
],
GitOperation::History,
)?
.map_or_else(Vec::new, |output| parse_history(&output)))
}
fn has_lfs_tracking(&self) -> bool {
fs::read_to_string(self.repository_dir.join(".gitattributes")).is_ok_and(|contents| {
LFS_PATTERNS.iter().all(|pattern| {
contents
.lines()
.any(|line| line.starts_with(pattern) && line.contains("filter=lfs"))
})
})
}
fn optional_output(
&self,
args: &[&str],
operation: GitOperation,
) -> Result<Option<String>, GitError> {
match self.run_command(args, operation, self.local_timeout, &|| false, &mut |_| {}) {
Ok(result) if result.status == 0 => Ok(nonblank(result.stdout)),
Ok(_) => Ok(None),
Err(GitError::Failed { .. }) => Ok(None),
Err(error) => Err(error),
}
}
fn optional_raw_output(
&self,
args: &[&str],
operation: GitOperation,
) -> Result<Option<String>, GitError> {
match self.run_command(args, operation, self.local_timeout, &|| false, &mut |_| {}) {
Ok(result) if result.status == 0 => Ok(Some(result.stdout)),
Ok(_) | Err(GitError::Failed { .. }) => Ok(None),
Err(error) => Err(error),
}
}
fn run_local(&self, args: &[&str], operation: GitOperation) -> Result<String, GitError> {
let result =
self.run_command(args, operation, self.local_timeout, &|| false, &mut |_| {})?;
self.success_or_error(operation, result)
.map(|result| result.stdout)
}
fn run_local_bytes(&self, args: &[&str], operation: GitOperation) -> Result<Vec<u8>, GitError> {
let result =
self.run_command(args, operation, self.local_timeout, &|| false, &mut |_| {})?;
self.success_or_error(operation, result)
.map(|result| result.stdout_bytes)
}
fn run_network(
&self,
args: &[&str],
operation: GitOperation,
is_cancelled: &dyn Fn() -> bool,
on_output: &mut dyn FnMut(GitOutput),
) -> Result<NetworkResult, GitError> {
let result = self.run_command(
args,
operation,
self.network_timeout,
is_cancelled,
on_output,
)?;
match self.success_or_error(operation, result) {
Ok(result) => Ok(NetworkResult {
output: combined_output(&result.stdout, &result.stderr),
}),
Err(GitError::Failed { output, .. }) if is_auth_error(&output) => {
let provider = self
.optional_output(&["remote", "get-url", "origin"], GitOperation::Remote)?
.as_deref()
.map(provider_from_url);
let platform = current_platform();
Err(GitError::Authentication {
operation,
provider,
platform,
guidance: auth_guidance(provider, platform),
})
}
Err(error) => Err(error),
}
}
fn run_command(
&self,
args: &[&str],
operation: GitOperation,
timeout: Duration,
is_cancelled: &dyn Fn() -> bool,
on_output: &mut dyn FnMut(GitOutput),
) -> Result<CommandResult, GitError> {
let mut command = Command::new(&self.executable);
command
.args(args)
.current_dir(&self.repository_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("GIT_TERMINAL_PROMPT", "0")
.env("GCM_INTERACTIVE", "never")
.env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes")
.env("GIT_LFS_SKIP_SMUDGE", "1")
.env("LC_ALL", "C")
.env("LANG", "C");
configure_process_group(&mut command);
prepend_tool_paths(&mut command);
let mut child = command
.spawn()
.map_err(|error| self.io_error(operation, error))?;
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let (sender, receiver) = mpsc::channel();
let stdout_thread = read_stream(stdout, GitOutputStream::Stdout, sender.clone());
let stderr_thread = read_stream(stderr, GitOutputStream::Stderr, sender);
let start = Instant::now();
let mut stdout_bytes = Vec::new();
let mut stderr_bytes = Vec::new();
loop {
drain_output(&receiver, &mut stdout_bytes, &mut stderr_bytes, on_output);
if is_cancelled() {
terminate_child(&mut child);
let _ = child.wait();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
drain_output(&receiver, &mut stdout_bytes, &mut stderr_bytes, on_output);
return Err(GitError::Cancelled {
operation,
output: combined_bytes(&stdout_bytes, &stderr_bytes),
});
}
if start.elapsed() >= timeout {
terminate_child(&mut child);
let _ = child.wait();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
drain_output(&receiver, &mut stdout_bytes, &mut stderr_bytes, on_output);
return Err(GitError::TimedOut {
operation,
timeout,
output: combined_bytes(&stdout_bytes, &stderr_bytes),
});
}
match child.try_wait() {
Ok(Some(status)) => {
let _ = stdout_thread.join();
let _ = stderr_thread.join();
drain_output(&receiver, &mut stdout_bytes, &mut stderr_bytes, on_output);
return Ok(CommandResult {
status: status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(),
stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
stdout_bytes,
});
}
Ok(None) => thread::sleep(Duration::from_millis(10)),
Err(error) => return Err(self.io_error(operation, error)),
}
}
}
fn success_or_error(
&self,
operation: GitOperation,
result: CommandResult,
) -> Result<CommandResult, GitError> {
if result.status == 0 {
Ok(result)
} else {
Err(GitError::Failed {
operation,
output: combined_output(&result.stdout, &result.stderr),
})
}
}
fn io_error(&self, operation: GitOperation, error: std::io::Error) -> GitError {
GitError::Io {
operation,
message: error.to_string(),
}
}
#[cfg(test)]
fn with_executable_and_timeouts(
repository_dir: impl Into<PathBuf>,
executable: impl Into<PathBuf>,
local_timeout: Duration,
network_timeout: Duration,
) -> Self {
Self {
repository_dir: repository_dir.into(),
executable: executable.into(),
local_timeout,
network_timeout,
}
}
}
struct CommandResult {
status: i32,
stdout: String,
stderr: String,
stdout_bytes: Vec<u8>,
}
fn read_stream(
mut stream: impl Read + Send + 'static,
kind: GitOutputStream,
sender: mpsc::Sender<(GitOutputStream, Vec<u8>)>,
) -> thread::JoinHandle<()> {
thread::spawn(move || {
let mut buffer = [0_u8; 4096];
loop {
match stream.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(read) => {
if sender.send((kind, buffer[..read].to_vec())).is_err() {
break;
}
}
}
}
})
}
fn drain_output(
receiver: &mpsc::Receiver<(GitOutputStream, Vec<u8>)>,
stdout: &mut Vec<u8>,
stderr: &mut Vec<u8>,
on_output: &mut dyn FnMut(GitOutput),
) {
while let Ok((stream, bytes)) = receiver.try_recv() {
match stream {
GitOutputStream::Stdout => stdout.extend_from_slice(&bytes),
GitOutputStream::Stderr => stderr.extend_from_slice(&bytes),
}
on_output(GitOutput {
stream,
text: String::from_utf8_lossy(&bytes).into_owned(),
});
}
}
#[cfg(unix)]
fn configure_process_group(command: &mut Command) {
use std::os::unix::process::CommandExt;
command.process_group(0);
}
#[cfg(not(unix))]
fn configure_process_group(_command: &mut Command) {}
#[cfg(unix)]
fn terminate_child(child: &mut std::process::Child) {
let group = format!("-{}", child.id());
let _ = Command::new("kill").args(["-TERM", &group]).status();
thread::sleep(Duration::from_millis(25));
if child.try_wait().ok().flatten().is_none() {
let _ = Command::new("kill").args(["-KILL", &group]).status();
}
}
#[cfg(windows)]
fn terminate_child(child: &mut std::process::Child) {
let _ = Command::new("taskkill")
.args(["/PID", &child.id().to_string(), "/T", "/F"])
.status();
let _ = child.kill();
}
fn prepend_tool_paths(command: &mut Command) {
#[cfg(not(target_os = "macos"))]
let _ = command;
#[cfg(target_os = "macos")]
{
let mut paths = vec![
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/usr/local/bin"),
];
paths.extend(
std::env::var_os("PATH")
.iter()
.flat_map(std::env::split_paths),
);
if let Ok(path) = std::env::join_paths(paths) {
command.env("PATH", path);
}
}
}
fn append_missing_lines(path: &Path, required: &[&str]) -> std::io::Result<()> {
let existing = fs::read_to_string(path).unwrap_or_default();
let lines = existing.lines().collect::<HashSet<_>>();
let missing = required
.iter()
.copied()
.filter(|line| !lines.contains(line))
.collect::<Vec<_>>();
if missing.is_empty() {
return Ok(());
}
let mut output = existing;
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push_str(&missing.join("\n"));
output.push('\n');
fs::write(path, output)
}
fn append_lfs_lines(path: &Path) -> std::io::Result<()> {
let required = LFS_PATTERNS
.iter()
.map(|pattern| format!("{pattern} filter=lfs diff=lfs merge=lfs -text"))
.collect::<Vec<_>>();
let refs = required.iter().map(String::as_str).collect::<Vec<_>>();
append_missing_lines(path, &refs)
}
fn parse_status(output: &[u8]) -> Vec<GitFileStatus> {
let fields = output.split(|byte| *byte == 0).collect::<Vec<_>>();
let mut files = Vec::new();
let mut index = 0;
while index < fields.len() {
let field = fields[index];
if field.len() < 4 {
index += 1;
continue;
}
let x = field[0] as char;
let y = field[1] as char;
let path = String::from_utf8_lossy(&field[3..]).into_owned();
let renamed = matches!(x, 'R' | 'C') || matches!(y, 'R' | 'C');
let old_path = if renamed {
index += 1;
fields
.get(index)
.filter(|old| !old.is_empty())
.map(|old| String::from_utf8_lossy(old).into_owned())
} else {
None
};
let kind = if x == '?' && y == '?' {
FileStatusKind::Untracked
} else if renamed {
FileStatusKind::Renamed
} else if x == 'A' || y == 'A' {
FileStatusKind::Added
} else if x == 'D' || y == 'D' {
FileStatusKind::Deleted
} else {
FileStatusKind::Modified
};
files.push(GitFileStatus {
path,
old_path,
kind,
staged: x != ' ' && x != '?',
unstaged: (y != ' ' && y != '?') || (x == '?' && y == '?'),
});
index += 1;
}
files
}
fn parse_history(output: &str) -> Vec<GitCommit> {
output
.split('\x1e')
.filter_map(|record| {
let fields = record.trim().splitn(4, '\x1f').collect::<Vec<_>>();
(!fields.first().copied().unwrap_or_default().is_empty()).then(|| GitCommit {
hash: fields[0].to_string(),
author: optional_field(fields.get(1).copied()),
date: optional_field(fields.get(2).copied()),
subject: optional_field(fields.get(3).copied()),
sync_status: SyncStatus::LocalOnly,
})
})
.collect()
}
fn optional_field(field: Option<&str>) -> Option<String> {
field
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn provider_from_url(remote_url: &str) -> GitProvider {
let url = remote_url.to_ascii_lowercase();
if url.contains("github.com") {
GitProvider::GitHub
} else if url.contains("gitlab") {
GitProvider::GitLab
} else {
GitProvider::GiteaForgejo
}
}
fn current_platform() -> GitPlatform {
if cfg!(target_os = "macos") {
GitPlatform::MacOs
} else if cfg!(windows) {
GitPlatform::Windows
} else {
GitPlatform::Linux
}
}
fn auth_guidance(provider: Option<GitProvider>, platform: GitPlatform) -> String {
let provider = match provider {
Some(GitProvider::GitHub) => "GitHub",
Some(GitProvider::GitLab) => "GitLab",
Some(GitProvider::GiteaForgejo) => "Gitea/Forgejo",
None => "the Git provider",
};
let credential = match platform {
GitPlatform::MacOs => "Keychain credential helper or an SSH key",
GitPlatform::Windows => "Git Credential Manager or an SSH key",
GitPlatform::Linux => "credential helper or an SSH key",
};
format!(
"Authentication failed for {provider} on {platform}. Configure a {credential} that works non-interactively."
)
}
fn is_auth_error(message: &str) -> bool {
let message = message.to_ascii_lowercase();
[
"authentication failed",
"permission denied",
"could not read username",
"terminal prompts disabled",
"repository not found",
"http basic: access denied",
]
.iter()
.any(|needle| message.contains(needle))
}
fn nonblank(value: String) -> Option<String> {
let value = value.trim().to_string();
(!value.is_empty()).then_some(value)
}
fn combined_output(stdout: &str, stderr: &str) -> String {
match (stdout.trim(), stderr.trim()) {
("", stderr) => stderr.to_string(),
(stdout, "") => stdout.to_string(),
(stdout, stderr) => format!("{stdout}\n{stderr}"),
}
}
fn combined_bytes(stdout: &[u8], stderr: &[u8]) -> String {
combined_output(
&String::from_utf8_lossy(stdout),
&String::from_utf8_lossy(stderr),
)
}
fn added_file_patch(path: &str, contents: &str) -> String {
let mut patch = format!("diff --git a/{path} b/{path}\n--- /dev/null\n+++ b/{path}\n");
patch.push_str(
&contents
.lines()
.map(|line| format!("+{line}"))
.collect::<Vec<_>>()
.join("\n"),
);
if contents.ends_with('\n') {
patch.push('\n');
}
patch
}
fn validate_relative_path(path: &str) -> Result<(), GitError> {
let path = Path::new(path);
if path.as_os_str().is_empty()
|| path.is_absolute()
|| !path
.components()
.all(|component| matches!(component, Component::Normal(_)))
{
return Err(GitError::Validation("invalid repository path".into()));
}
Ok(())
}
fn is_object_name(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 64
&& value.chars().all(|character| character.is_ascii_hexdigit())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangedFile {
pub path: String,
pub old_path: Option<String>,
pub kind: FileStatusKind,
}
fn parse_changed_files(output: &[u8]) -> Vec<ChangedFile> {
let fields = output.split(|byte| *byte == 0).collect::<Vec<_>>();
let mut changes = Vec::new();
let mut index = 0;
while index < fields.len() {
let status = String::from_utf8_lossy(fields[index]);
if status.is_empty() {
break;
}
index += 1;
let Some(path) = fields.get(index).filter(|path| !path.is_empty()) else {
break;
};
let first_path = String::from_utf8_lossy(path).into_owned();
index += 1;
let code = status.as_bytes()[0] as char;
if matches!(code, 'R' | 'C') {
let Some(new_path) = fields.get(index).filter(|path| !path.is_empty()) else {
break;
};
changes.push(ChangedFile {
path: String::from_utf8_lossy(new_path).into_owned(),
old_path: Some(first_path),
kind: FileStatusKind::Renamed,
});
index += 1;
} else {
changes.push(ChangedFile {
path: first_path,
old_path: None,
kind: match code {
'A' => FileStatusKind::Added,
'D' => FileStatusKind::Deleted,
_ => FileStatusKind::Modified,
},
});
}
}
changes
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ReconcileEntityType {
Post,
PostTranslation,
Script,
Template,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReconcileAction {
Created,
Updated,
Deleted,
Renamed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReconcileEvent {
pub project_id: String,
pub entity_type: ReconcileEntityType,
pub entity_id: String,
pub action: ReconcileAction,
pub path: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReconcileReport {
pub events: Vec<ReconcileEvent>,
}
#[derive(Debug, Clone)]
struct EntityAtPath {
entity_type: ReconcileEntityType,
id: String,
}
pub fn reconcile_changed_files(
conn: &DbConnection,
data_dir: &Path,
project_id: &str,
changes: &[ChangedFile],
mut emit: impl FnMut(&ReconcileEvent),
) -> EngineResult<ReconcileReport> {
let before = entities_by_path(conn, project_id)?;
let posts_changed = changes.iter().any(|change| relevant_path(change, "posts/"));
let scripts_changed = changes
.iter()
.any(|change| relevant_path(change, "scripts/"));
let templates_changed = changes
.iter()
.any(|change| relevant_path(change, "templates/"));
if posts_changed {
let report =
crate::engine::post::rebuild_posts_from_filesystem(conn, data_dir, project_id)?;
fail_rebuild_errors("posts", report.errors)?;
}
if scripts_changed {
let report = crate::engine::script_rebuild::rebuild_scripts_from_filesystem(
conn, data_dir, project_id,
)?;
fail_rebuild_errors("scripts", report.errors)?;
}
if templates_changed {
let report = crate::engine::template_rebuild::rebuild_templates_from_filesystem(
conn, data_dir, project_id,
)?;
fail_rebuild_errors("templates", report.errors)?;
}
let rebuilt = entities_by_path(conn, project_id)?;
for change in changes {
match change.kind {
FileStatusKind::Deleted => {
delete_entity_at_path(conn, before.get(&change.path))?;
}
FileStatusKind::Renamed => {
if let Some(old_path) = change.old_path.as_deref() {
let entity = before.get(old_path).filter(|entity| {
!rebuilt.values().any(|current| {
current.id == entity.id && current.entity_type == entity.entity_type
})
});
delete_entity_at_path(conn, entity)?;
}
}
_ => {}
}
}
if posts_changed {
crate::engine::post::rebuild_all_links(conn, data_dir, project_id)?;
}
let after = entities_by_path(conn, project_id)?;
let mut events = Vec::new();
for change in changes {
if !is_reconciled_path(&change.path)
&& !change.old_path.as_deref().is_some_and(is_reconciled_path)
{
continue;
}
let event = match change.kind {
FileStatusKind::Deleted => before.get(&change.path).map(|entity| ReconcileEvent {
project_id: project_id.to_string(),
entity_type: entity.entity_type,
entity_id: entity.id.clone(),
action: ReconcileAction::Deleted,
path: change.path.clone(),
}),
FileStatusKind::Renamed => after.get(&change.path).map(|entity| ReconcileEvent {
project_id: project_id.to_string(),
entity_type: entity.entity_type,
entity_id: entity.id.clone(),
action: ReconcileAction::Renamed,
path: change.path.clone(),
}),
FileStatusKind::Added | FileStatusKind::Modified | FileStatusKind::Untracked => {
after.get(&change.path).map(|entity| ReconcileEvent {
project_id: project_id.to_string(),
entity_type: entity.entity_type,
entity_id: entity.id.clone(),
action: if before.values().any(|old| old.id == entity.id) {
ReconcileAction::Updated
} else {
ReconcileAction::Created
},
path: change.path.clone(),
})
}
};
if let Some(event) = event {
emit(&event);
events.push(event);
}
}
Ok(ReconcileReport { events })
}
fn relevant_path(change: &ChangedFile, prefix: &str) -> bool {
change.path.starts_with(prefix)
|| change
.old_path
.as_deref()
.is_some_and(|path| path.starts_with(prefix))
}
fn is_reconciled_path(path: &str) -> bool {
(path.starts_with("posts/") && path.ends_with(".md"))
|| (path.starts_with("scripts/") && path.ends_with(".lua"))
|| (path.starts_with("templates/") && path.ends_with(".liquid"))
}
fn fail_rebuild_errors(category: &str, errors: Vec<String>) -> EngineResult<()> {
if errors.is_empty() {
Ok(())
} else {
Err(EngineError::Parse(format!(
"Git reconciliation failed for {category}: {}",
errors.join("; ")
)))
}
}
fn entities_by_path(
conn: &DbConnection,
project_id: &str,
) -> EngineResult<HashMap<String, EntityAtPath>> {
let mut entities = HashMap::new();
for post in crate::db::queries::post::list_posts_by_project(conn, project_id)? {
if !post.file_path.is_empty() {
entities.insert(
post.file_path.clone(),
EntityAtPath {
entity_type: ReconcileEntityType::Post,
id: post.id.clone(),
},
);
}
for translation in
crate::db::queries::post_translation::list_post_translations_by_post(conn, &post.id)?
{
if !translation.file_path.is_empty() {
entities.insert(
translation.file_path,
EntityAtPath {
entity_type: ReconcileEntityType::PostTranslation,
id: translation.id,
},
);
}
}
}
for script in crate::db::queries::script::list_scripts_by_project(conn, project_id)? {
if !script.file_path.is_empty() {
entities.insert(
script.file_path,
EntityAtPath {
entity_type: ReconcileEntityType::Script,
id: script.id,
},
);
}
}
for template in crate::db::queries::template::list_templates_by_project(conn, project_id)? {
if !template.file_path.is_empty() {
entities.insert(
template.file_path,
EntityAtPath {
entity_type: ReconcileEntityType::Template,
id: template.id,
},
);
}
}
Ok(entities)
}
fn delete_entity_at_path(conn: &DbConnection, entity: Option<&EntityAtPath>) -> EngineResult<()> {
let Some(entity) = entity else {
return Ok(());
};
match entity.entity_type {
ReconcileEntityType::Post => crate::db::queries::post::delete_post(conn, &entity.id)?,
ReconcileEntityType::PostTranslation => {
crate::db::queries::post_translation::delete_post_translation(conn, &entity.id)?
}
ReconcileEntityType::Script => crate::db::queries::script::delete_script(conn, &entity.id)?,
ReconcileEntityType::Template => {
crate::db::queries::template::delete_template(conn, &entity.id)?
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
#[test]
fn parses_nul_status_with_renames_and_spaces() {
let files =
parse_status(b"M staged name.md\0 M work.md\0?? new file.md\0R new.md\0old.md\0");
assert_eq!(files.len(), 4);
assert!(files[0].staged);
assert!(files[1].unstaged);
assert_eq!(files[2].kind, FileStatusKind::Untracked);
assert_eq!(files[3].path, "new.md");
assert_eq!(files[3].old_path.as_deref(), Some("old.md"));
}
#[test]
fn parses_name_status_renames() {
assert_eq!(
parse_changed_files(b"A\0posts/new.md\0R100\0scripts/old.lua\0scripts/new.lua\0"),
vec![
ChangedFile {
path: "posts/new.md".into(),
old_path: None,
kind: FileStatusKind::Added,
},
ChangedFile {
path: "scripts/new.lua".into(),
old_path: Some("scripts/old.lua".into()),
kind: FileStatusKind::Renamed,
},
]
);
}
#[test]
fn rejects_paths_outside_repository() {
assert!(validate_relative_path("../secret").is_err());
assert!(validate_relative_path("/secret").is_err());
assert!(validate_relative_path("posts/ok.md").is_ok());
}
#[test]
fn reconciliation_reuses_rebuild_paths_for_updates_deletes_and_renames() {
use crate::db::Database;
use crate::db::queries::project::{insert_project, make_test_project};
use crate::model::{ScriptKind, TemplateKind};
let db = Database::open_in_memory().unwrap();
db.migrate().unwrap();
crate::db::fts::ensure_fts_tables(db.conn()).unwrap();
insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("posts")).unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::create_dir_all(dir.path().join("templates")).unwrap();
let post = crate::engine::post::create_post(
db.conn(),
dir.path(),
"p1",
"Original title",
Some("Body"),
Vec::new(),
Vec::new(),
None,
Some("en"),
None,
)
.unwrap();
let post = crate::engine::post::publish_post(db.conn(), dir.path(), &post.id).unwrap();
let script = crate::engine::script::create_script(
db.conn(),
"p1",
"Tool",
ScriptKind::Utility,
"function main() return true end",
Some("main"),
)
.unwrap();
let script =
crate::engine::script::publish_script(db.conn(), dir.path(), &script.id).unwrap();
let template = crate::engine::template::create_template(
db.conn(),
"p1",
"Layout",
TemplateKind::Post,
"<article>{{ content }}</article>",
)
.unwrap();
let template =
crate::engine::template::publish_template(db.conn(), dir.path(), &template.id).unwrap();
let post_path = dir.path().join(&post.file_path);
let post_contents = fs::read_to_string(&post_path).unwrap();
fs::write(
&post_path,
post_contents.replace("title: Original title", "title: Pulled title"),
)
.unwrap();
fs::remove_file(dir.path().join(&script.file_path)).unwrap();
let old_template_path = template.file_path.clone();
let new_template_path = "templates/renamed-layout.liquid".to_string();
fs::rename(
dir.path().join(&old_template_path),
dir.path().join(&new_template_path),
)
.unwrap();
let changes = vec![
ChangedFile {
path: post.file_path.clone(),
old_path: None,
kind: FileStatusKind::Modified,
},
ChangedFile {
path: script.file_path.clone(),
old_path: None,
kind: FileStatusKind::Deleted,
},
ChangedFile {
path: new_template_path.clone(),
old_path: Some(old_template_path),
kind: FileStatusKind::Renamed,
},
];
let mut emitted = Vec::new();
let report = reconcile_changed_files(db.conn(), dir.path(), "p1", &changes, |event| {
emitted.push(event.clone());
})
.unwrap();
assert_eq!(report.events, emitted);
assert_eq!(
crate::db::queries::post::get_post_by_id(db.conn(), &post.id)
.unwrap()
.title,
"Pulled title"
);
assert!(crate::db::queries::script::get_script_by_id(db.conn(), &script.id).is_err());
assert_eq!(
crate::db::queries::template::get_template_by_id(db.conn(), &template.id)
.unwrap()
.file_path,
new_template_path
);
assert!(report.events.iter().any(|event| {
event.entity_id == script.id && event.action == ReconcileAction::Deleted
}));
assert!(report.events.iter().any(|event| {
event.entity_id == template.id && event.action == ReconcileAction::Renamed
}));
let diff = crate::engine::metadata_diff::compute_metadata_diff(db.conn(), dir.path(), "p1")
.unwrap();
assert!(
diff.diffs.is_empty(),
"metadata differences: {:?}",
diff.diffs
);
}
#[cfg(unix)]
#[test]
fn initialization_preserves_ignore_entries_and_configures_every_lfs_pattern() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".gitignore"), "/custom/\n").unwrap();
let executable = dir.path().join("fake-git");
fs::write(
&executable,
"#!/bin/sh\nif [ \"$1\" = init ]; then mkdir -p .git; exit 0; fi\nif [ \"$1\" = symbolic-ref ]; then echo master; exit 0; fi\nif [ \"$1\" = remote ]; then exit 2; fi\nexit 0\n",
)
.unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
let engine = GitEngine::with_executable_and_timeouts(
dir.path(),
executable,
Duration::from_secs(5),
Duration::from_secs(1),
);
let repository = engine.initialize().unwrap();
assert!(repository.is_initialized);
assert_eq!(repository.current_branch.as_deref(), Some("master"));
let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(gitignore.contains("/custom/"));
assert!(
GITIGNORE_LINES
.iter()
.all(|line| gitignore.lines().any(|found| found == *line))
);
let attributes = fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
assert!(LFS_PATTERNS.iter().all(|pattern| {
attributes
.lines()
.any(|line| line.starts_with(pattern) && line.contains("filter=lfs"))
}));
}
#[cfg(unix)]
#[test]
fn timeout_and_cancellation_kill_the_process_and_preserve_output() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let executable = dir.path().join("fake-git");
fs::write(&executable, "#!/bin/sh\nprintf 'started\\n'\nsleep 5\n").unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
let engine = GitEngine::with_executable_and_timeouts(
dir.path(),
&executable,
Duration::from_secs(1),
Duration::from_secs(3),
);
let error = engine.fetch(|| false, |_| {}).unwrap_err();
assert!(
matches!(
error,
GitError::TimedOut {
operation: GitOperation::Fetch,
ref output,
..
} if output.contains("started")
),
"{error:?}"
);
let cancelled = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&cancelled);
let output = Arc::new(Mutex::new(String::new()));
let streamed = Arc::clone(&output);
let engine = GitEngine::with_executable_and_timeouts(
dir.path(),
executable,
Duration::from_secs(1),
Duration::from_secs(3),
);
let error = engine
.push(
move || {
let seen = !streamed.lock().unwrap().is_empty();
if seen {
flag.store(true, Ordering::Release);
}
flag.load(Ordering::Acquire)
},
|chunk| output.lock().unwrap().push_str(&chunk.text),
)
.unwrap_err();
assert!(matches!(error, GitError::Cancelled { .. }));
assert!(output.lock().unwrap().contains("started"));
}
#[cfg(unix)]
#[test]
fn authentication_errors_are_structured_by_provider_and_platform() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let executable = dir.path().join("fake-git");
fs::write(
&executable,
"#!/bin/sh\nif [ \"$1\" = remote ]; then echo git@gitlab.com:owner/repo.git; exit 0; fi\necho 'fatal: Authentication failed' >&2\nexit 128\n",
)
.unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
let engine = GitEngine::with_executable_and_timeouts(
dir.path(),
executable,
Duration::from_secs(1),
Duration::from_secs(1),
);
let error = engine.fetch(|| false, |_| {}).unwrap_err();
assert!(matches!(
error,
GitError::Authentication {
provider: Some(GitProvider::GitLab),
..
}
));
}
}