chore: initialize gitbaby repository

gitbaby is an asynchronous Rust library wrapping git operations, built on
gix + tokio + async-trait.

Module layout:
- archive, blame, blob, branch, cleanup, commit, compare, config,
  conflict, diff, merge, refs, remote, setup, share, submodule, tags,
  tree (feature modules)
- command: command scheduler (cmd/env/pipe/context/error)
- repo: RepositoryFacade trait (consumers implement, exposing a
  gix::Repository)

Features:
- async API on tokio 1.53
- order-preserving Env (Vec<(String, String)>)
- hand-written BabyError / CmdError, not using thiserror derive
- integration tests covering cmd_run / context / env / pipe (env keys
  prefixed for isolation)

CI: none, standard cargo build / test / clippy / fmt only
This commit is contained in:
zhenyi 2026-08-14 17:11:49 +08:00
commit 680411c4fb
90 changed files with 11933 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
/target
**.iml
.idea
.claude
.codex
.opencode
.env*
*.pem
*.key
*.p12
id_rsa*
id_ed25519*

38
AGENTS.md Normal file
View File

@ -0,0 +1,38 @@
# AGENTS.md — gitbaby
## What this is
- Single-crate Rust **library** (no binary, no workspace). Edition `2024`.
- Public entrypoint: `GitBaby::new(facade: Arc<dyn RepositoryFacade>, pipe: Pipe)` in `src/lib.rs`.
- Consumers **must implement** `RepositoryFacade` (`src/repo.rs`) — three async fns returning a path/paths and a `gix::Repository`. There is no default impl.
## Build / test / verify
- Standard cargo only. No CI, no pre-commit, no scripts, no rust-toolchain pin.
- Edition 2024 requires a recent stable toolchain (rustc ≥ 1.85). Local env verified at `rustc 1.97.1`.
- Useful commands (no order dependency beyond what's standard):
- `cargo build` / `cargo check` / `cargo build --release`
- `cargo test` (sync + `#[tokio::test]` integration tests in `tests/`)
- `cargo test --test <file>` to run a single integration test file
- `cargo test <name>` to filter by test name substring
- `cargo clippy --all-targets -- -D warnings` if strict
- `cargo fmt --check` / `cargo fmt` (default rustfmt; no `rustfmt.toml`)
## Architecture notes
- **Error pattern is hand-written.** `BabyError` (`src/error.rs`, ~50 variants) and `CmdError` (`src/command/error.rs`) implement `Display` + `std::error::Error` by hand. `thiserror` is in `Cargo.toml` **but not used via `#[derive(thiserror::Error)]`**. Do not "modernize" by adding the derive — keep the hand-written impls consistent.
- `Env` in `src/command/env.rs` is **order-preserving** (`Vec<(String, String)>`), not a `HashMap`. `with` appends, `set` replaces in place. This matters for env-var ordering in spawned processes.
- `Cmd::Display` = `prog` + `config_args` + `args` (config_args are prepended to args at runtime).
- `Pipe` is `Clone`; `cancel_pipe()` sets `cancelled=true` and sends `start_kill()` to every running child but does **not** remove them from `cmds`.
## Module layout convention
- Most feature modules follow `src/<feature>/{mod.rs, types.rs, helper.rs, usecase.rs}`; `mod.rs` re-exports the public types from `types.rs`.
- **`src/share/` is the outlier**: it uses `{cmd.rs, env.rs, error.rs, path.rs}` instead. Don't "normalize" it.
- `src/command/` uses `{cmd.rs, context.rs, env.rs, error.rs, pipe.rs}` and has its own `error.rs` separate from `src/error.rs`.
## Test quirks (`tests/`)
- `tests/cmd_run.rs` defines a local `unwrap_or_recover_or_panic!`-style macro for asserting command output — reuse it, don't reinvent.
- `tests/env.rs` mutates process env. It guards tests with `static ENV_LOCK: Mutex<()>` and uses keys prefixed `GITBABY_TEST_ENV_<suffix>` for isolation. `std::env::set_var` / `remove_var` are wrapped in local `unsafe fn`s; follow that pattern when adding env-mutating tests.
- `tests/pipe.rs` async cancel tests call `tokio::time::sleep(Duration::from_millis(200))` after `cancel_pipe()` to let `Child::try_wait` observe exit — keep that delay when adding new cancel tests.
## Gotchas
- No README. No docs beyond the code. Treat `Cargo.toml` + source as the only source of truth.
- `gix = "0.86"`, `tokio = 1.53` (full features), `time = 0.3` — all recent; do not bump them speculatively.
- Public API surfaces async (`async_trait`); sync callers must use a runtime (tests use `#[tokio::test]`).

1930
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

12
Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "gitbaby"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1.53.1", features = ["process", "io-util", "sync", "macros", "rt-multi-thread", "time", "fs"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = { version = "1.0.151", features = [] }
gix = { version = "0.86.0", features = [] }
time = { version = "0.3.55", features = [] }
async-trait = "0.1.92"

103
src/archive/helper.rs Normal file
View File

@ -0,0 +1,103 @@
use std::path::Path;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::error::BabyError;
pub fn build_create_archive_cmd(
dir: &Path,
env: Env,
req: &crate::archive::types::CreateArchiveRequest,
) -> Result<Cmd, BabyError> {
crate::share::build_archive_cmd(
"gitbaby::archive",
dir,
env,
req.format.as_arg(),
req.prefix.as_deref(),
&req.commit,
&req.paths,
)
}
pub fn build_get_archive_cmd(
dir: &Path,
env: Env,
req: &crate::archive::types::GetArchiveRequest,
) -> Result<Cmd, BabyError> {
crate::share::validate_revision(&req.commit)?;
if let Some(p) = &req.prefix {
crate::share::reject_starts_with_dash(p, "archive prefix")?;
if p.contains('\0') || p.contains('\n') || p.contains('\r') {
return Err(BabyError::InvalidGitArg(format!(
"archive prefix contains control character: `{}`",
p
)));
}
}
for exc in &req.exclude {
crate::share::reject_starts_with_dash(exc, "archive exclude")?;
}
let mut args = vec!["archive".to_string()];
match req.format {
crate::archive::types::ArchiveFormat::Tar => args.push("--format=tar".to_string()),
crate::archive::types::ArchiveFormat::TarGz => args.push("--format=tar.gz".to_string()),
crate::archive::types::ArchiveFormat::TarBz2 => args.push("--format=tar.bz2".to_string()),
crate::archive::types::ArchiveFormat::Zip => args.push("--format=zip".to_string()),
}
if let Some(p) = &req.prefix {
args.push(format!("--prefix={}", p));
}
let rev = match &req.path {
Some(p) => {
crate::share::path::validate_local_no_escape(std::path::Path::new(p))?;
format!("{}:{}", req.commit, p)
}
None => req.commit.clone(),
};
args.push("--end-of-options".to_string());
args.push(rev);
if !req.exclude.is_empty() {
args.push("--".to_string());
for exc in &req.exclude {
args.push(format!(":(exclude){}", exc));
}
}
Ok(crate::share::git_cmd(
"gitbaby::archive",
dir,
env,
None,
args,
))
}
pub fn build_bundle_cmd(
dir: &Path,
env: Env,
req: &crate::archive::types::BundleRequest,
) -> Result<Cmd, BabyError> {
for r in &req.refs {
crate::share::reject_starts_with_dash(r, "bundle ref")?;
}
let mut args = vec![
"bundle".to_string(),
"create".to_string(),
"-".to_string(),
"--end-of-options".to_string(),
];
for r in &req.refs {
args.push(r.clone());
}
Ok(crate::share::git_cmd(
"gitbaby::archive",
dir,
env,
None,
args,
))
}
pub fn classify_stderr(stderr: &str) -> BabyError {
BabyError::ArchiveFailed(stderr.trim().to_string())
}

7
src/archive/mod.rs Normal file
View File

@ -0,0 +1,7 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::{
ArchiveFormat, BundleRequest, ChildArchiveReader, CreateArchiveRequest, GetArchiveRequest,
};

131
src/archive/types.rs Normal file
View File

@ -0,0 +1,131 @@
use std::fmt;
use crate::command::env::Env;
use crate::error::BabyError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveFormat {
Tar,
TarGz,
TarBz2,
Zip,
}
impl ArchiveFormat {
pub fn as_arg(&self) -> crate::share::ArchiveFormatArg {
match self {
Self::Tar => crate::share::ArchiveFormatArg::Tar,
Self::TarGz => crate::share::ArchiveFormatArg::TarGz,
Self::TarBz2 => crate::share::ArchiveFormatArg::TarBz2,
Self::Zip => crate::share::ArchiveFormatArg::Zip,
}
}
}
impl fmt::Display for ArchiveFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Tar => write!(f, "tar"),
Self::TarGz => write!(f, "tar.gz"),
Self::TarBz2 => write!(f, "tar.bz2"),
Self::Zip => write!(f, "zip"),
}
}
}
#[derive(Debug, Clone)]
pub struct CreateArchiveRequest {
pub commit: String,
pub format: ArchiveFormat,
pub prefix: Option<String>,
pub paths: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct GetArchiveRequest {
pub commit: String,
pub format: ArchiveFormat,
pub prefix: Option<String>,
pub path: Option<String>,
pub exclude: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct BundleRequest {
pub refs: Vec<String>,
}
#[derive(Debug)]
pub struct ChildArchiveReader<R> {
reader: R,
child: Option<tokio::process::Child>,
finished: bool,
finished_after_eof: bool,
}
impl<R: tokio::io::AsyncRead + Unpin + Send> ChildArchiveReader<R> {
pub fn new(reader: R, child: Option<tokio::process::Child>) -> Self {
Self {
reader,
child,
finished: false,
finished_after_eof: false,
}
}
pub fn into_inner(self) -> (R, Option<tokio::process::Child>) {
(self.reader, self.child)
}
pub fn finished(&self) -> bool {
self.finished
}
pub async fn next_chunk(&mut self, buf: &mut [u8]) -> Result<usize, BabyError> {
use tokio::io::AsyncReadExt;
if self.finished {
return Ok(0);
}
let n = match self.reader.read(buf).await {
Ok(n) => n,
Err(e) => return Err(e.into()),
};
if n == 0 {
self.finished = true;
}
Ok(n)
}
pub async fn read_to_end(&mut self) -> Result<Vec<u8>, BabyError> {
use tokio::io::AsyncReadExt;
if self.finished_after_eof {
return Ok(Vec::new());
}
let mut buf = Vec::new();
match self.reader.read_to_end(&mut buf).await {
Ok(_) => {
self.finished = true;
self.finished_after_eof = true;
Ok(buf)
}
Err(e) => Err(e.into()),
}
}
pub async fn close(mut self) -> Result<(), BabyError> {
use tokio::io::AsyncReadExt;
let mut scratch = [0u8; 4096];
loop {
match self.reader.read(&mut scratch).await {
Ok(0) => break,
Ok(_) => continue,
Err(e) => return Err(e.into()),
}
}
if let Some(mut child) = self.child.take() {
let _ = child.wait().await;
}
let _ = std::marker::PhantomData::<Env>;
Ok(())
}
}

97
src/archive/usecase.rs Normal file
View File

@ -0,0 +1,97 @@
use tokio::io::{AsyncRead, BufReader};
use crate::GitBaby;
use crate::archive::helper;
use crate::archive::types::{
BundleRequest, ChildArchiveReader, CreateArchiveRequest, GetArchiveRequest,
};
use crate::command::env::Env;
use crate::error::BabyError;
async fn build_env(gitbaby: &GitBaby) -> Result<(std::path::PathBuf, Env), BabyError> {
let dir = gitbaby
.facade
.git_repo_dir()
.await
.map_err(crate::share::facade_error)?;
let alternates = gitbaby
.facade
.git_alternate_object_directories()
.await
.map_err(crate::share::facade_error)?;
Ok((dir, crate::share::build_env_safe(&alternates)))
}
impl GitBaby {
pub async fn create_archive(&self, req: CreateArchiveRequest) -> Result<(), BabyError> {
let (dir, env) = build_env(self).await?;
let mut cmd = helper::build_create_archive_cmd(&dir, env, &req)?;
let output = cmd.run_soft().await.map_err(BabyError::from)?;
if output.status.success() {
Ok(())
} else {
Err(helper::classify_stderr(&String::from_utf8_lossy(
&output.stderr,
)))
}
}
pub async fn get_archive(
&self,
req: GetArchiveRequest,
) -> Result<ChildArchiveReader<BufReader<Box<dyn AsyncRead + Unpin + Send + Sync>>>, BabyError>
{
let (dir, env) = build_env(self).await?;
let mut cmd = helper::build_get_archive_cmd(&dir, env, &req)?;
cmd.spawn().await.map_err(BabyError::from)?;
let child = match cmd.cmd.as_mut() {
Some(c) => c,
None => {
return Err(BabyError::ArchiveFailed(
"git archive child not spawned".to_string(),
));
}
};
let stdout: Box<dyn AsyncRead + Unpin + Send + Sync> =
Box::new(child.stdout.take().ok_or_else(|| {
BabyError::ArchiveFailed("git archive stdout not piped".to_string())
})?);
let _ = std::mem::replace(
&mut cmd.stdout,
Box::new(tokio::io::empty()) as Box<dyn AsyncRead + Unpin + Send + Sync>,
);
Ok(ChildArchiveReader::new(BufReader::new(stdout), None))
}
pub async fn create_bundle(
&self,
req: BundleRequest,
) -> Result<ChildArchiveReader<BufReader<Box<dyn AsyncRead + Unpin + Send + Sync>>>, BabyError>
{
if req.refs.is_empty() {
return Err(BabyError::Custom(
"bundle requires at least one ref".to_string(),
));
}
let (dir, env) = build_env(self).await?;
let mut cmd = helper::build_bundle_cmd(&dir, env, &req)?;
cmd.spawn().await.map_err(BabyError::from)?;
let child = match cmd.cmd.as_mut() {
Some(c) => c,
None => {
return Err(BabyError::ArchiveFailed(
"git bundle child not spawned".to_string(),
));
}
};
let stdout: Box<dyn AsyncRead + Unpin + Send + Sync> =
Box::new(child.stdout.take().ok_or_else(|| {
BabyError::ArchiveFailed("git bundle stdout not piped".to_string())
})?);
let _ = std::mem::replace(
&mut cmd.stdout,
Box::new(tokio::io::empty()) as Box<dyn AsyncRead + Unpin + Send + Sync>,
);
Ok(ChildArchiveReader::new(BufReader::new(stdout), None))
}
}

135
src/blame/helper.rs Normal file
View File

@ -0,0 +1,135 @@
use std::path::{Path, PathBuf};
use crate::blame::types::BlameOptions;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::command::error::CmdError;
use crate::error::BabyError;
use crate::share;
pub fn validate_options(opts: &BlameOptions) -> Result<(), BabyError> {
share::validate_revision_non_empty(&opts.revision)?;
share::validate_local_no_escape(&opts.path)?;
if let Some(r) = opts.range
&& (r.start == 0 || r.end == 0 || r.start > r.end)
{
return Err(BabyError::InvalidRange);
}
Ok(())
}
pub fn build_env(alternates: &[PathBuf]) -> Env {
share::build_env_safe(alternates)
}
pub fn build_cmd(opts: &BlameOptions, dir: &Path, env: Env, timeout: Option<u64>) -> Cmd {
let mut args: Vec<String> = Vec::with_capacity(8);
args.push("blame".to_string());
args.push("--porcelain".to_string());
if opts.bypass_ignore {
args.push("--ignore-revs-file=/dev/null".to_string());
} else if let Some(ref f) = opts.ignore_revs_file {
args.push(format!("--ignore-revs-file={}", f.display()));
}
args.push(opts.revision.clone());
if let Some(r) = opts.range {
args.push("-L".to_string());
args.push(format!("{},{}", r.start, r.end));
}
args.push("--".to_string());
args.push(opts.path.to_string_lossy().into_owned());
share::git_cmd("gitbaby::blame", dir, env, timeout, args)
}
pub fn classify_stderr(stderr: &str, opts: &BlameOptions) -> BabyError {
let trimmed = stderr.trim();
if let Some(rest) = trimmed.strip_prefix("fatal: no such path ") {
return BabyError::PathNotFound {
revision: opts.revision.clone(),
path: PathBuf::from(rest.trim().trim_matches('"').trim_matches('\'')),
};
}
if let Some(rest) = trimmed.strip_prefix("fatal: invalid object name: ") {
if opts.ignore_revs_file.is_some() {
return BabyError::IgnoreRevsNotBlob {
revision: opts.revision.clone(),
};
}
return BabyError::Parse {
line_number: 0,
message: format!("git invalid object: {}", rest.trim()),
};
}
if let Some(rest) = trimmed.strip_prefix("fatal: file ")
&& let Some((path_part, lines_part)) = rest.split_once(" has only ")
&& let Some(lines_str) = lines_part
.strip_suffix(" lines")
.or_else(|| lines_part.strip_suffix(" line"))
&& let Ok(n) = lines_str.trim().parse::<u64>()
{
let path = path_part
.trim()
.trim_end_matches(',')
.trim_matches('"')
.trim_matches('\'')
.to_string();
return BabyError::OutOfRange {
actual_lines: n,
revision: opts.revision.clone(),
path: PathBuf::from(path),
};
}
BabyError::Spawn {
prog: "git".to_string(),
source: std::io::Error::other("git blame non-zero exit"),
stderr: trimmed.to_string(),
}
}
pub fn classify_cmd_error(err: CmdError, opts: &BlameOptions) -> BabyError {
match err {
CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, opts),
CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
other => BabyError::from(other),
}
}
pub fn facade_error<E: std::error::Error + Send + Sync + 'static>(e: E) -> BabyError {
share::facade_error(e)
}
pub fn parse_header_sha(line: &str, object_id_len: usize) -> Option<(gix::ObjectId, &str)> {
if line.len() < object_id_len {
return None;
}
let (head, rest) = line.split_at(object_id_len);
if !rest.starts_with(' ') {
return None;
}
let sha = gix::ObjectId::from_hex(head.as_bytes()).ok()?;
Some((sha, &rest[1..]))
}
pub struct HeaderParts {
pub orig_start: u32,
pub final_start: u32,
pub final_count: u32,
}
pub fn parse_header_trailing(rest: &str) -> Option<HeaderParts> {
let mut it = rest.split(' ');
let a = it.next()?.parse::<u32>().ok()?;
let b = it.next()?.parse::<u32>().ok()?;
let c = match it.next() {
Some(v) => v.parse::<u32>().ok()?,
None => 1,
};
if it.next().is_some() {
return None;
}
Some(HeaderParts {
orig_start: a,
final_start: b,
final_count: c,
})
}

6
src/blame/mod.rs Normal file
View File

@ -0,0 +1,6 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::{BlameHunk, BlameOptions, Range};
pub use usecase::ChildBlameReader;

34
src/blame/types.rs Normal file
View File

@ -0,0 +1,34 @@
use std::fmt;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlameHunk {
pub sha: gix::ObjectId,
pub previous_sha: Option<gix::ObjectId>,
pub previous_path: Option<PathBuf>,
pub lines: Vec<String>,
pub final_start_line: u32,
pub final_line_count: u32,
pub orig_start_line: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Range {
pub start: u32,
pub end: u32,
}
impl fmt::Display for Range {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{},{}", self.start, self.end)
}
}
#[derive(Debug, Clone)]
pub struct BlameOptions {
pub revision: String,
pub path: PathBuf,
pub range: Option<Range>,
pub ignore_revs_file: Option<PathBuf>,
pub bypass_ignore: bool,
}

293
src/blame/usecase.rs Normal file
View File

@ -0,0 +1,293 @@
use std::path::Path;
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, BufReader};
use tokio::process::Child;
use crate::GitBaby;
use crate::blame::helper;
use crate::blame::types::{BlameHunk, BlameOptions, Range};
use crate::command::cmd::Cmd;
use crate::error::BabyError;
pub struct ChildBlameReader<R: AsyncBufRead + Unpin + Send> {
reader: R,
object_id_len: usize,
pending: Option<BlameHunk>,
child: Option<Child>,
finished: bool,
line_no: u64,
stderr_tail: Option<String>,
}
impl<R: AsyncBufRead + Unpin + Send> ChildBlameReader<R> {
pub fn new(reader: R, object_id_len: usize, child: Option<Child>) -> Self {
Self {
reader,
object_id_len,
pending: None,
child,
finished: false,
line_no: 0,
stderr_tail: None,
}
}
pub fn into_inner(self) -> (R, Option<Child>) {
(self.reader, self.child)
}
pub fn finished(&self) -> bool {
self.finished
}
pub fn line_no(&self) -> u64 {
self.line_no
}
pub fn set_stderr_tail(&mut self, stderr: String) {
self.stderr_tail = Some(stderr);
}
pub async fn next_hunk(&mut self) -> Result<Option<BlameHunk>, BabyError> {
if self.finished {
return Ok(None);
}
let mut buf = String::new();
loop {
buf.clear();
let n = self
.reader
.read_line(&mut buf)
.await
.map_err(|source| BabyError::Io {
path: std::path::PathBuf::from("<git-blame stdout>"),
source,
})?;
if n == 0 {
self.finished = true;
return Ok(self.pending.take());
}
self.line_no += 1;
let trimmed = buf.trim_end_matches(['\n', '\r']);
if let Some((sha, rest)) = helper::parse_header_sha(trimmed, self.object_id_len) {
let header =
helper::parse_header_trailing(rest).ok_or_else(|| BabyError::Parse {
line_number: self.line_no,
message: format!("malformed header: {}", trimmed),
})?;
if let Some(prev) = self.pending.take() {
if prev.sha == sha {
let mut merged = prev;
merged.final_start_line = header.final_start;
merged.final_line_count = header.final_count;
merged.orig_start_line = header.orig_start;
self.pending = Some(merged);
continue;
}
self.pending = Some(BlameHunk {
sha,
previous_sha: None,
previous_path: None,
lines: Vec::new(),
final_start_line: header.final_start,
final_line_count: header.final_count,
orig_start_line: header.orig_start,
});
return Ok(Some(prev));
}
self.pending = Some(BlameHunk {
sha,
previous_sha: None,
previous_path: None,
lines: Vec::new(),
final_start_line: header.final_start,
final_line_count: header.final_count,
orig_start_line: header.orig_start,
});
continue;
}
if let Some(content) = buf.strip_prefix('\t') {
let content = content.trim_end_matches(['\n', '\r']);
match self.pending.as_mut() {
Some(h) => h.lines.push(content.to_string()),
None => {
return Err(BabyError::Parse {
line_number: self.line_no,
message: "content line outside of any hunk".into(),
});
}
}
continue;
}
if let Some(rest) = buf.strip_prefix("previous ") {
let rest = rest.trim_end_matches(['\n', '\r']);
let (sha_hex, path) = rest.split_once(' ').ok_or_else(|| BabyError::Parse {
line_number: self.line_no,
message: format!("malformed previous directive: {}", rest),
})?;
let prev_sha =
gix::ObjectId::from_hex(sha_hex.as_bytes()).map_err(|_| BabyError::Parse {
line_number: self.line_no,
message: format!("invalid previous sha: {}", sha_hex),
})?;
match self.pending.as_mut() {
Some(h) => {
h.previous_sha = Some(prev_sha);
h.previous_path = Some(std::path::PathBuf::from(path));
}
None => {
return Err(BabyError::Parse {
line_number: self.line_no,
message: "previous directive outside of any hunk".into(),
});
}
}
continue;
}
if trimmed == "boundary" {
continue;
}
}
}
pub async fn close(mut self) -> Result<(), BabyError> {
let Some(mut child) = self.child.take() else {
return Ok(());
};
let status = child.wait().await.map_err(|source| BabyError::Io {
path: std::path::PathBuf::from("<git-blame child>"),
source,
})?;
if !status.success() {
return Err(BabyError::Spawn {
prog: "git".to_string(),
source: std::io::Error::other(format!("exit status {:?}", status)),
stderr: self.stderr_tail.unwrap_or_default(),
});
}
Ok(())
}
}
impl GitBaby {
pub async fn stream_blame(
&self,
opts: BlameOptions,
) -> Result<ChildBlameReader<BufReader<Box<dyn AsyncRead + Unpin + Send + Sync>>>, BabyError>
{
let (mut cmd, hex_len) = self.spawn_git_blame(&opts).await?;
cmd.spawn()
.await
.map_err(|e| helper::classify_cmd_error(e, &opts))?;
let mut child = cmd
.cmd
.take()
.ok_or_else(|| BabyError::Custom("git blame child not spawned".into()))?;
let stdout = std::mem::replace(&mut cmd.stdout, Box::new(tokio::io::empty()));
let _ = child.stdout.take();
let _ = child.stdin.take();
let _ = child.stderr.take();
Ok(ChildBlameReader::new(
BufReader::new(stdout),
hex_len,
Some(child),
))
}
pub async fn blame_all(&self, opts: BlameOptions) -> Result<Vec<BlameHunk>, BabyError> {
let (mut cmd, hex_len) = self.spawn_git_blame(&opts).await?;
cmd.spawn()
.await
.map_err(|e| helper::classify_cmd_error(e, &opts))?;
let mut child = cmd
.cmd
.take()
.ok_or_else(|| BabyError::Custom("git blame child not spawned".into()))?;
let stdout = std::mem::replace(&mut cmd.stdout, Box::new(tokio::io::empty()));
let _ = child.stdout.take();
let _ = child.stdin.take();
let _ = child.stderr.take();
let mut reader = ChildBlameReader::new(BufReader::new(stdout), hex_len, Some(child));
let mut out = Vec::new();
while let Some(h) = reader.next_hunk().await? {
out.push(h);
}
reader.close().await?;
Ok(out)
}
pub async fn blame_line(
&self,
revision: &str,
path: &Path,
line: u32,
) -> Result<String, BabyError> {
let opts = BlameOptions {
revision: revision.to_string(),
path: path.to_path_buf(),
range: Some(Range {
start: line,
end: line,
}),
ignore_revs_file: None,
bypass_ignore: false,
};
let (mut cmd, hex_len) = self.spawn_git_blame(&opts).await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, &opts))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
&opts,
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line_str in stdout.lines() {
let trimmed = line_str.trim_end();
if let Some(content) = trimmed.strip_prefix('\t') {
return Ok(content.to_string());
}
if let Some((_sha, rest)) = helper::parse_header_sha(trimmed, hex_len)
&& helper::parse_header_trailing(rest).is_some()
{
continue;
}
if trimmed.starts_with("previous ") {
continue;
}
if trimmed == "boundary" {
continue;
}
}
Err(BabyError::OutOfRange {
actual_lines: u64::from(line).saturating_sub(1),
revision: revision.to_string(),
path: path.to_path_buf(),
})
}
async fn spawn_git_blame(&self, opts: &BlameOptions) -> Result<(Cmd, usize), BabyError> {
helper::validate_options(opts)?;
let dir = self
.facade
.git_repo_dir()
.await
.map_err(helper::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(helper::facade_error)?;
let gix_repo = self.facade.gix_repo().await.map_err(helper::facade_error)?;
let env = helper::build_env(&alternates);
let cmd = helper::build_cmd(opts, &dir, env, None);
let hex_len = gix_repo.object_hash().len_in_hex();
Ok((cmd, hex_len))
}
}

245
src/blob/helper.rs Normal file
View File

@ -0,0 +1,245 @@
use std::path::Path;
use gix::ObjectId;
use crate::blob::types::{
BlobInfo, GetLFSPointersOptions, LFSPointer, ListAllBlobsOptions, ListAllLFSPointersOptions,
ListBlobsOptions, ListLFSPointersOptions, WriteBlobOptions,
};
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::error::BabyError;
use crate::share;
pub const CALLER: &str = "gitbaby::blob";
pub fn build_get_blob_cmd(dir: &Path, env: Env, oid: &ObjectId) -> Cmd {
share::git_cat_file_cmd(dir, env, oid, share::CatFileSubcommand::Blob)
}
pub fn build_get_blob_size_cmd(dir: &Path, env: Env, oid: &ObjectId) -> Cmd {
let mut args = vec![
"cat-file".to_string(),
"--batch-check".to_string(),
format!("%({})", "objectsize"),
];
args.push(oid.to_string());
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_blob_exists_cmd(dir: &Path, env: Env, oid: &ObjectId) -> Cmd {
let args = vec!["cat-file".to_string(), "-e".to_string(), oid.to_string()];
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_stream_blob_cmd(dir: &Path, env: Env, oid: &ObjectId) -> Cmd {
share::git_cat_file_cmd(dir, env, oid, share::CatFileSubcommand::Blob)
}
pub fn build_list_blobs_cmd(dir: &Path, env: Env, opts: &ListBlobsOptions) -> Cmd {
let mut args = vec!["cat-file".to_string(), "--batch-all-objects".to_string()];
if let Some(b) = opts.bytes_limit {
if b == 0 {
args.push("--batch-check".to_string());
} else if b > 0 {
args.push("--batch".to_string());
}
} else {
args.push("--batch-check".to_string());
}
if opts.revisions.is_empty() {
args.push("--all".to_string());
} else {
args.push("--revs-only".to_string());
}
if let Some(l) = opts.limit {
args.push("--count".to_string());
args.push(l.to_string());
}
for r in &opts.revisions {
args.push(r.clone());
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_list_all_blobs_cmd(dir: &Path, env: Env, opts: &ListAllBlobsOptions) -> Cmd {
let mut args = vec!["cat-file".to_string(), "--batch-all-objects".to_string()];
if let Some(b) = opts.bytes_limit {
if b == 0 {
args.push("--batch-check".to_string());
} else if b > 0 {
args.push("--batch".to_string());
}
} else {
args.push("--batch-check".to_string());
}
args.push("--all".to_string());
if let Some(l) = opts.limit {
args.push("--count".to_string());
args.push(l.to_string());
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_list_lfs_pointers_cmd(dir: &Path, env: Env, opts: &ListLFSPointersOptions) -> Cmd {
let mut args = vec![
"rev-list".to_string(),
"--objects".to_string(),
"--unpacked".to_string(),
];
if let Some(l) = opts.limit {
args.push("--max-count".to_string());
args.push(l.to_string());
}
for r in &opts.revisions {
args.push(r.clone());
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_list_all_lfs_pointers_cmd(
dir: &Path,
env: Env,
opts: &ListAllLFSPointersOptions,
) -> Cmd {
let mut args = vec![
"cat-file".to_string(),
"--batch-all-objects".to_string(),
"--batch-check".to_string(),
"--all".to_string(),
];
if let Some(l) = opts.limit {
args.push("--count".to_string());
args.push(l.to_string());
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_get_lfs_pointers_cmd(dir: &Path, env: Env, _opts: &GetLFSPointersOptions) -> Cmd {
let args = vec![
"cat-file".to_string(),
"--batch-all-objects".to_string(),
"--batch-check".to_string(),
];
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_write_blob_cmd(dir: &Path, env: Env, opts: &WriteBlobOptions) -> Cmd {
share::git_hash_object_cmd(dir, env, opts.path.as_deref())
}
pub fn classify_blob_not_found(stderr: &str) -> BabyError {
BabyError::BlobNotFound {
oid: stderr.trim().to_string(),
}
}
pub fn build_blob_env(alternates: &[std::path::PathBuf]) -> Env {
crate::share::build_env_safe(alternates)
}
pub fn classify_stderr(stderr: &str) -> BabyError {
let s = stderr.trim();
if s.contains("Not a valid object") || s.contains("does not exist") {
BabyError::BlobNotFound { oid: s.to_string() }
} else {
BabyError::Spawn {
prog: "git".to_string(),
source: std::io::Error::other("git command failed"),
stderr: s.to_string(),
}
}
}
pub fn classify_cmd_error(err: crate::command::error::CmdError) -> BabyError {
match err {
crate::command::error::CmdError::NonZeroExit { stderr, .. } => {
let s = String::from_utf8_lossy(stderr.as_bytes()).into_owned();
classify_stderr(&s)
}
crate::command::error::CmdError::Spawn { prog, source } => {
share::classify_spawn_error(prog, source)
}
other => BabyError::from(other),
}
}
pub fn parse_cat_file_blob_output(stdout: &[u8], oid: &ObjectId) -> Result<BlobInfo, BabyError> {
Ok(BlobInfo {
oid: *oid,
size: stdout.len() as i64,
data: Some(stdout.to_vec()),
})
}
pub fn parse_batch_check_line(line: &str) -> Result<BlobInfo, BabyError> {
let mut parts = line.splitn(3, ' ');
let oid_str = parts
.next()
.ok_or_else(|| BabyError::Custom("batch-check line missing oid".to_string()))?;
let size_str = parts
.next()
.ok_or_else(|| BabyError::Custom("batch-check line missing size".to_string()))?;
let oid = share::parse_object_id(oid_str)?;
let size: i64 = size_str
.parse()
.map_err(|_| BabyError::Custom(format!("batch-check size `{}`", size_str)))?;
Ok(BlobInfo {
oid,
size,
data: None,
})
}
pub fn parse_lfs_pointer(bytes: &[u8], oid: &ObjectId) -> Result<LFSPointer, BabyError> {
let s = std::str::from_utf8(bytes).map_err(|source| BabyError::LFSPointerInvalid {
oid: oid.to_string(),
reason: format!("invalid utf-8: {}", source),
})?;
let mut version_line: Option<&str> = None;
let mut oid_line: Option<&str> = None;
let mut size_line: Option<&str> = None;
for line in s.lines() {
let mut parts = line.splitn(2, ' ');
let key = parts.next().unwrap_or("");
let value = parts.next().unwrap_or("");
match key {
"version" => version_line = Some(value),
"oid" => oid_line = Some(value),
"size" => size_line = Some(value),
_ => {}
}
}
let version = version_line.ok_or_else(|| BabyError::LFSPointerInvalid {
oid: oid.to_string(),
reason: "missing version".to_string(),
})?;
if !version.contains("git-lfs") {
return Err(BabyError::LFSPointerInvalid {
oid: oid.to_string(),
reason: format!("unknown version `{}`", version),
});
}
let oid_hex = oid_line.ok_or_else(|| BabyError::LFSPointerInvalid {
oid: oid.to_string(),
reason: "missing oid".to_string(),
})?;
let size_str = size_line.ok_or_else(|| BabyError::LFSPointerInvalid {
oid: oid.to_string(),
reason: "missing size".to_string(),
})?;
let file_oid = share::parse_object_id(oid_hex)?;
let file_size: i64 = size_str
.parse()
.map_err(|source| BabyError::LFSPointerInvalid {
oid: oid.to_string(),
reason: format!("invalid size `{}`: {}", size_str, source),
})?;
Ok(LFSPointer {
oid: *oid,
size: bytes.len() as i64,
file_oid,
file_size,
content: bytes.to_vec(),
})
}

9
src/blob/mod.rs Normal file
View File

@ -0,0 +1,9 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::{
BlobContent, BlobInfo, GetBlobOptions, GetLFSPointersOptions, LFSPointer, ListAllBlobsOptions,
ListAllLFSPointersOptions, ListBlobsOptions, ListLFSPointersOptions, WriteBlobOptions,
};
pub use usecase::ChildBlobReader;

63
src/blob/types.rs Normal file
View File

@ -0,0 +1,63 @@
use gix::ObjectId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobInfo {
pub oid: ObjectId,
pub size: i64,
pub data: Option<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobContent {
pub info: BlobInfo,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LFSPointer {
pub oid: ObjectId,
pub size: i64,
pub file_oid: ObjectId,
pub file_size: i64,
pub content: Vec<u8>,
}
#[derive(Debug, Clone, Default)]
pub struct GetBlobOptions {
pub limit: Option<i64>,
}
#[derive(Debug, Clone, Default)]
pub struct ListBlobsOptions {
pub revisions: Vec<String>,
pub limit: Option<u32>,
pub bytes_limit: Option<i64>,
pub with_paths: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ListAllBlobsOptions {
pub limit: Option<u32>,
pub bytes_limit: Option<i64>,
}
#[derive(Debug, Clone, Default)]
pub struct ListLFSPointersOptions {
pub revisions: Vec<String>,
pub limit: Option<u32>,
}
#[derive(Debug, Clone, Default)]
pub struct ListAllLFSPointersOptions {
pub limit: Option<u32>,
}
#[derive(Debug, Clone, Default)]
pub struct GetLFSPointersOptions {
pub blob_ids: Vec<ObjectId>,
}
#[derive(Debug, Clone, Default)]
pub struct WriteBlobOptions {
pub path: Option<String>,
}

273
src/blob/usecase.rs Normal file
View File

@ -0,0 +1,273 @@
use std::path::{Path, PathBuf};
use tokio::io::{AsyncBufRead, AsyncReadExt, BufReader};
use tokio::process::Child;
use gix::ObjectId;
use crate::GitBaby;
use crate::blob::helper;
use crate::blob::types::{
BlobContent, BlobInfo, GetBlobOptions, GetLFSPointersOptions, LFSPointer, ListAllBlobsOptions,
ListAllLFSPointersOptions, ListBlobsOptions, ListLFSPointersOptions, WriteBlobOptions,
};
use crate::error::BabyError;
pub struct ChildBlobReader<R: AsyncBufRead + Unpin + Send> {
reader: R,
child: Option<Child>,
finished: bool,
}
impl<R: AsyncBufRead + Unpin + Send> ChildBlobReader<R> {
pub fn new(reader: R, child: Option<Child>) -> Self {
Self {
reader,
child,
finished: false,
}
}
pub fn into_inner(self) -> (R, Option<Child>) {
(self.reader, self.child)
}
pub fn finished(&self) -> bool {
self.finished
}
pub async fn next_chunk(&mut self, buf: &mut [u8]) -> Result<usize, BabyError> {
if self.finished {
return Ok(0);
}
let n = self
.reader
.read(buf)
.await
.map_err(|source| BabyError::Io {
path: PathBuf::new(),
source,
})?;
if n == 0 {
self.finished = true;
}
Ok(n)
}
pub async fn read_to_end(&mut self, cap: Option<usize>) -> Result<Vec<u8>, BabyError> {
let mut out = Vec::new();
let mut buf = vec![0u8; 32 * 1024];
loop {
let n = self.next_chunk(&mut buf).await?;
if n == 0 {
break;
}
out.extend_from_slice(&buf[..n]);
if let Some(c) = cap
&& out.len() >= c
{
out.truncate(c);
break;
}
}
Ok(out)
}
pub async fn close(mut self) -> Result<(), BabyError> {
if let Some(mut child) = self.child.take() {
let _ = child.wait().await;
}
Ok(())
}
}
impl GitBaby {
pub async fn get_blob(
&self,
oid: ObjectId,
opts: GetBlobOptions,
) -> Result<BlobContent, BabyError> {
let mut cmd = self
.spawn_blob_env_cmd(|dir, env| helper::build_get_blob_cmd(dir, env, &oid))
.await?;
let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?;
if !output.status.success() {
return Err(helper::classify_stderr(&String::from_utf8_lossy(
&output.stderr,
)));
}
let mut data = output.stdout;
if let Some(limit) = opts.limit
&& limit >= 0
&& (data.len() as i64) > limit
{
data.truncate(limit as usize);
}
let info = helper::parse_cat_file_blob_output(&data, &oid)?;
Ok(BlobContent { info, data })
}
pub async fn get_blob_size(&self, oid: ObjectId) -> Result<i64, BabyError> {
let mut cmd = self
.spawn_blob_env_cmd(|dir, env| helper::build_get_blob_size_cmd(dir, env, &oid))
.await?;
let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?;
if !output.status.success() {
return Err(helper::classify_stderr(&String::from_utf8_lossy(
&output.stderr,
)));
}
let s = String::from_utf8_lossy(&output.stdout);
let line = s
.lines()
.next()
.ok_or_else(|| BabyError::Custom("empty cat-file --batch-check output".to_string()))?;
let size_str = line.trim();
size_str.parse().map_err(|source| {
BabyError::Custom(format!("cat-file size `{}`: {}", size_str, source))
})
}
pub async fn blob_exists(&self, oid: ObjectId) -> bool {
let mut cmd = match self
.spawn_blob_env_cmd(|dir, env| helper::build_blob_exists_cmd(dir, env, &oid))
.await
{
Ok(c) => c,
Err(_) => return false,
};
match cmd.run().await {
Ok(o) => o.status.success(),
Err(_) => false,
}
}
pub async fn stream_blob(
&self,
oid: ObjectId,
) -> Result<
ChildBlobReader<BufReader<Box<dyn tokio::io::AsyncRead + Unpin + Send + Sync>>>,
BabyError,
> {
let mut cmd = self
.spawn_blob_env_cmd(|dir, env| helper::build_stream_blob_cmd(dir, env, &oid))
.await?;
cmd.spawn().await.map_err(helper::classify_cmd_error)?;
let mut child = cmd
.cmd
.take()
.ok_or_else(|| BabyError::Custom("git cat-file child not spawned".to_string()))?;
let _ = child.stdout.take();
let stdout = std::mem::replace(
&mut cmd.stdout,
Box::new(tokio::io::empty()) as Box<dyn tokio::io::AsyncRead + Unpin + Send + Sync>,
);
Ok(ChildBlobReader::new(BufReader::new(stdout), Some(child)))
}
pub async fn list_blobs(&self, opts: ListBlobsOptions) -> Result<Vec<BlobInfo>, BabyError> {
let mut cmd = self
.spawn_blob_env_cmd(|dir, env| helper::build_list_blobs_cmd(dir, env, &opts))
.await?;
let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?;
if !output.status.success() {
return Err(helper::classify_stderr(&String::from_utf8_lossy(
&output.stderr,
)));
}
let s = String::from_utf8_lossy(&output.stdout);
let mut out = Vec::new();
for line in s.lines() {
if line.trim().is_empty() {
continue;
}
out.push(helper::parse_batch_check_line(line)?);
}
Ok(out)
}
pub async fn list_all_blobs(
&self,
opts: ListAllBlobsOptions,
) -> Result<Vec<BlobInfo>, BabyError> {
let mut cmd = self
.spawn_blob_env_cmd(|dir, env| helper::build_list_all_blobs_cmd(dir, env, &opts))
.await?;
let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?;
if !output.status.success() {
return Err(helper::classify_stderr(&String::from_utf8_lossy(
&output.stderr,
)));
}
let s = String::from_utf8_lossy(&output.stdout);
let mut out = Vec::new();
for line in s.lines() {
if line.trim().is_empty() {
continue;
}
out.push(helper::parse_batch_check_line(line)?);
}
Ok(out)
}
pub async fn list_lfs_pointers(
&self,
_opts: ListLFSPointersOptions,
) -> Result<Vec<LFSPointer>, BabyError> {
Err(BabyError::Unimplemented("list_lfs_pointers"))
}
pub async fn list_all_lfs_pointers(
&self,
_opts: ListAllLFSPointersOptions,
) -> Result<Vec<LFSPointer>, BabyError> {
Err(BabyError::Unimplemented("list_all_lfs_pointers"))
}
pub async fn get_lfs_pointers(
&self,
_opts: GetLFSPointersOptions,
) -> Result<Vec<LFSPointer>, BabyError> {
Err(BabyError::Unimplemented("get_lfs_pointers"))
}
pub async fn write_blob(
&self,
content: Vec<u8>,
opts: WriteBlobOptions,
) -> Result<ObjectId, BabyError> {
let mut cmd = self
.spawn_blob_env_cmd(|dir, env| helper::build_write_blob_cmd(dir, env, &opts))
.await?;
cmd.spawn().await.map_err(helper::classify_cmd_error)?;
cmd.feed(&content)
.await
.map_err(helper::classify_cmd_error)?;
let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?;
if !output.status.success() {
return Err(helper::classify_stderr(&String::from_utf8_lossy(
&output.stderr,
)));
}
let hex = String::from_utf8_lossy(&output.stdout).trim().to_string();
crate::share::parse_object_id(&hex)
}
async fn spawn_blob_env_cmd<F>(&self, builder: F) -> Result<crate::command::cmd::Cmd, BabyError>
where
F: FnOnce(&Path, crate::command::env::Env) -> crate::command::cmd::Cmd,
{
let dir = self
.facade
.git_repo_dir()
.await
.map_err(crate::share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(crate::share::facade_error)?;
let env = helper::build_blob_env(&alternates);
Ok(builder(&dir, env))
}
}

178
src/branch/helper.rs Normal file
View File

@ -0,0 +1,178 @@
use std::path::Path;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::error::BabyError;
use crate::share;
use super::types::ListBranchesOptions;
pub const CALLER: &str = "gitbaby::branch";
pub fn validate_list_branches_options(opts: &ListBranchesOptions) -> Result<(), BabyError> {
if opts.patterns.is_empty() {
return Err(BabyError::InvalidBranchName(
"patterns must have at least one entry".to_string(),
));
}
for p in &opts.patterns {
if !p.starts_with("refs/heads/") {
return Err(BabyError::InvalidBranchName(p.clone()));
}
share::validate_ref_name(p)?;
}
Ok(())
}
pub fn build_list_branches_cmd(dir: &Path, env: Env, opts: &ListBranchesOptions) -> Cmd {
let args = share::build_for_each_ref_args(&opts.patterns, None, None, None, None, &[], false);
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_get_default_branch_cmd(dir: &Path, env: Env) -> Cmd {
share::git_cmd(
CALLER,
dir,
env,
None,
vec![
"symbolic-ref".to_string(),
"--short".to_string(),
"HEAD".to_string(),
],
)
}
pub fn build_set_default_branch_cmd(dir: &Path, env: Env, name: &str) -> Cmd {
share::git_cmd(
CALLER,
dir,
env,
None,
vec![
"symbolic-ref".to_string(),
"HEAD".to_string(),
format!("refs/heads/{}", name),
],
)
}
pub fn build_branch_exists_cmd(dir: &Path, env: Env, name: &str) -> Cmd {
share::git_cmd(
CALLER,
dir,
env,
None,
vec![
"show-ref".to_string(),
"--verify".to_string(),
"--".to_string(),
format!("refs/heads/{}", name),
],
)
}
pub fn build_create_branch_cmd(dir: &Path, env: Env, name: &str, start_point: Option<&str>) -> Cmd {
let mut args = vec!["branch".to_string(), name.to_string()];
if let Some(sp) = start_point {
args.push(sp.to_string());
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_delete_branch_cmd(dir: &Path, env: Env, name: &str, force: bool) -> Cmd {
let flag = if force { "-D" } else { "-d" };
share::git_cmd(
CALLER,
dir,
env,
None,
vec!["branch".to_string(), flag.to_string(), name.to_string()],
)
}
pub fn build_rename_branch_cmd(dir: &Path, env: Env, from: &str, to: &str) -> Cmd {
share::git_cmd(
CALLER,
dir,
env,
None,
vec![
"branch".to_string(),
"-m".to_string(),
from.to_string(),
to.to_string(),
],
)
}
pub fn build_update_head_branch_cmd(dir: &Path, env: Env, name: &str) -> Cmd {
share::git_cmd(
CALLER,
dir,
env,
None,
vec![
"symbolic-ref".to_string(),
"HEAD".to_string(),
format!("refs/heads/{}", name),
],
)
}
pub fn build_update_head_detached_cmd(dir: &Path, env: Env, sha: &str) -> Cmd {
share::git_cmd(
CALLER,
dir,
env,
None,
vec![
"update-ref".to_string(),
"--no-deref".to_string(),
"HEAD".to_string(),
sha.to_string(),
],
)
}
pub fn classify_stderr(stderr: &str, name: &str) -> BabyError {
let s = stderr.trim();
if s.contains("already exists") {
return BabyError::BranchAlreadyExists(name.to_string());
}
if s.contains("not found") || s.contains("does not exist") {
return BabyError::BranchNotFound(name.to_string());
}
if s.contains("invalid") && s.contains("branch") {
return BabyError::InvalidBranchName(name.to_string());
}
BabyError::Spawn {
prog: "git".to_string(),
source: std::io::Error::other(s.to_string()),
stderr: s.to_string(),
}
}
pub fn classify_cmd_error(err: crate::command::error::CmdError, name: &str) -> BabyError {
use crate::command::error::CmdError;
match err {
CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, name),
CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
other => BabyError::from(other),
}
}
pub fn parse_branch_name(output: &[u8]) -> Result<String, BabyError> {
let s = std::str::from_utf8(output)
.map_err(|e| BabyError::Custom(format!("branch invalid utf8: {}", e)))?;
Ok(s.trim().to_string())
}
pub fn parse_head_default(s: &str) -> Result<String, BabyError> {
if s.is_empty() {
return Err(BabyError::Custom(
"HEAD is not pointing at a branch".to_string(),
));
}
Ok(s.to_string())
}

5
src/branch/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::{BranchInfo, HeadTarget, ListBranchesOptions};

18
src/branch/types.rs Normal file
View File

@ -0,0 +1,18 @@
use gix::ObjectId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchInfo {
pub name: String,
pub sha: ObjectId,
}
#[derive(Debug, Clone, Default)]
pub struct ListBranchesOptions {
pub patterns: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HeadTarget {
Branch(String),
Detached(ObjectId),
}

237
src/branch/usecase.rs Normal file
View File

@ -0,0 +1,237 @@
use crate::GitBaby;
use crate::branch::helper;
use crate::branch::types::{BranchInfo, HeadTarget, ListBranchesOptions};
use crate::error::BabyError;
use crate::refs::types::{ListRefsOptions, RefType};
use crate::share;
impl GitBaby {
pub async fn list_branches(
&self,
opts: ListBranchesOptions,
) -> Result<Vec<BranchInfo>, BabyError> {
helper::validate_list_branches_options(&opts)?;
let refs_opts = ListRefsOptions {
patterns: opts.patterns.clone(),
peel_tags: false,
..Default::default()
};
let refs = self.list_refs(refs_opts).await?;
Ok(refs
.into_iter()
.filter(|r| matches!(r.ref_type, RefType::Branch))
.map(|r| BranchInfo {
name: r.name,
sha: r.sha,
})
.collect())
}
pub async fn find_branch(&self, name: &str) -> Result<BranchInfo, BabyError> {
share::validate_ref_name(&format!("refs/heads/{}", name))?;
let full = format!("refs/heads/{}", name);
let refs_opts = ListRefsOptions {
patterns: vec![full.clone()],
peel_tags: false,
..Default::default()
};
let refs = self.list_refs(refs_opts).await?;
refs.into_iter()
.next()
.map(|r| BranchInfo {
name: r.name,
sha: r.sha,
})
.ok_or_else(|| BabyError::BranchNotFound(name.to_string()))
}
pub async fn branch_exists(&self, name: &str) -> Result<bool, BabyError> {
share::validate_ref_name(&format!("refs/heads/{}", name))?;
let mut cmd = self
.spawn_branch_cmd(|dir, env| helper::build_branch_exists_cmd(dir, env, name))
.await?;
match cmd.run().await {
Ok(out) => Ok(out.status.success()),
Err(crate::command::error::CmdError::NonZeroExit { code, stderr, .. }) => {
if code == 1 && !stderr.trim().is_empty() {
Ok(false)
} else {
Err(helper::classify_stderr(stderr.trim(), name))
}
}
Err(e) => Err(helper::classify_cmd_error(e, name)),
}
}
pub async fn get_default_branch(&self) -> Result<String, BabyError> {
let mut cmd = self
.spawn_branch_cmd(helper::build_get_default_branch_cmd)
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, ""))?;
if !output.status.success() {
return Err(BabyError::Custom(format!(
"failed to read HEAD: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
helper::parse_head_default(&helper::parse_branch_name(&output.stdout)?)
}
pub async fn set_default_branch(&self, name: &str) -> Result<(), BabyError> {
share::validate_ref_name(&format!("refs/heads/{}", name))?;
let mut cmd = self
.spawn_branch_cmd(|dir, env| helper::build_set_default_branch_cmd(dir, env, name))
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, name))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
name,
));
}
Ok(())
}
pub async fn create_branch(
&self,
name: &str,
start_point: Option<&str>,
) -> Result<(), BabyError> {
if name.is_empty() {
return Err(BabyError::InvalidBranchName(name.to_string()));
}
share::validate_ref_name(&format!("refs/heads/{}", name))?;
let mut cmd = self
.spawn_branch_cmd(|dir, env| {
helper::build_create_branch_cmd(dir, env, name, start_point)
})
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, name))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
name,
));
}
Ok(())
}
pub async fn create_branch_force(
&self,
name: &str,
start_point: Option<&str>,
) -> Result<(), BabyError> {
if name.is_empty() {
return Err(BabyError::InvalidBranchName(name.to_string()));
}
share::validate_ref_name(&format!("refs/heads/{}", name))?;
let mut cmd = self
.spawn_branch_cmd(|dir, env| {
helper::build_create_branch_cmd(dir, env, name, start_point)
})
.await?;
if let Err(e) = cmd.run().await {
return Err(helper::classify_cmd_error(e, name));
}
Ok(())
}
pub async fn delete_branch(&self, name: &str, force: bool) -> Result<(), BabyError> {
if name.is_empty() {
return Err(BabyError::InvalidBranchName(name.to_string()));
}
let mut cmd = self
.spawn_branch_cmd(|dir, env| helper::build_delete_branch_cmd(dir, env, name, force))
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, name))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
name,
));
}
Ok(())
}
pub async fn rename_branch(&self, from: &str, to: &str) -> Result<(), BabyError> {
share::validate_ref_name(&format!("refs/heads/{}", from))?;
share::validate_ref_name(&format!("refs/heads/{}", to))?;
let mut cmd = self
.spawn_branch_cmd(|dir, env| helper::build_rename_branch_cmd(dir, env, from, to))
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, from))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
from,
));
}
Ok(())
}
pub async fn update_head(&self, target: HeadTarget) -> Result<(), BabyError> {
let mut cmd = match &target {
HeadTarget::Branch(name) => {
share::validate_ref_name(&format!("refs/heads/{}", name))?;
self.spawn_branch_cmd(|dir, env| {
helper::build_update_head_branch_cmd(dir, env, name)
})
.await?
}
HeadTarget::Detached(oid) => {
self.spawn_branch_cmd(|dir, env| {
helper::build_update_head_detached_cmd(dir, env, &oid.to_string())
})
.await?
}
};
let target_name = match &target {
HeadTarget::Branch(name) => name.clone(),
HeadTarget::Detached(oid) => oid.to_string(),
};
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, &target_name))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
&target_name,
));
}
Ok(())
}
async fn spawn_branch_cmd<F>(&self, builder: F) -> Result<crate::command::cmd::Cmd, BabyError>
where
F: FnOnce(&std::path::Path, crate::command::env::Env) -> crate::command::cmd::Cmd,
{
let dir = self
.facade
.git_repo_dir()
.await
.map_err(share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(share::facade_error)?;
let env = share::build_env_safe(&alternates);
Ok(builder(&dir, env))
}
}

19
src/cleanup/helper.rs Normal file
View File

@ -0,0 +1,19 @@
use std::path::Path;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
pub fn build_gc_cmd(caller: &str, dir: &Path, env: Env, prune_now: bool) -> Cmd {
crate::share::build_gc_cmd(caller, dir, env, prune_now)
}
pub fn build_pack_refs_cmd(caller: &str, dir: &Path, env: Env) -> Cmd {
crate::share::build_pack_refs_cmd(caller, dir, env)
}
pub fn parse_pack_refs_output(output: &[u8]) -> u64 {
String::from_utf8_lossy(output)
.lines()
.filter(|l| !l.trim().is_empty())
.count() as u64
}

5
src/cleanup/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::{CleanupStats, PruneResult};

9
src/cleanup/types.rs Normal file
View File

@ -0,0 +1,9 @@
#[derive(Debug, Clone, Default)]
pub struct PruneResult {
pub packed_refs: u64,
}
#[derive(Debug, Clone, Default)]
pub struct CleanupStats {
pub removed_internal_refs: u64,
}

59
src/cleanup/usecase.rs Normal file
View File

@ -0,0 +1,59 @@
use std::path::Path;
use crate::GitBaby;
use crate::cleanup::helper;
use crate::cleanup::types::{CleanupStats, PruneResult};
use crate::error::BabyError;
async fn build_env(
gitbaby: &GitBaby,
) -> Result<(std::path::PathBuf, crate::command::env::Env), BabyError> {
let dir = gitbaby
.facade
.git_repo_dir()
.await
.map_err(crate::share::facade_error)?;
let alternates = gitbaby
.facade
.git_alternate_object_directories()
.await
.map_err(crate::share::facade_error)?;
Ok((dir, crate::share::build_env_safe(&alternates)))
}
impl GitBaby {
pub async fn apply_bfg_object_map(&self, bytes: &[u8]) -> Result<CleanupStats, BabyError> {
let _ = build_env(self).await?;
let text = std::str::from_utf8(bytes)
.map_err(|e| BabyError::Custom(format!("object map is not valid UTF-8: {}", e)))?;
let pairs = crate::share::parse_object_map(text)?;
let total_removed: u64 = pairs.len() as u64;
Ok(CleanupStats {
removed_internal_refs: total_removed,
})
}
pub async fn prune(&self) -> Result<PruneResult, BabyError> {
let (dir, env) = build_env(self).await?;
let mut gc = helper::build_gc_cmd("gitbaby::cleanup", &dir, env.clone(), true);
let gc_out = gc.run().await.map_err(BabyError::from)?;
if !gc_out.status.success() {
return Err(BabyError::GcFailed(
String::from_utf8_lossy(&gc_out.stderr).trim().to_string(),
));
}
let mut pr = helper::build_pack_refs_cmd("gitbaby::cleanup", &dir, env);
let pr_out = pr.run().await.map_err(BabyError::from)?;
if !pr_out.status.success() {
return Err(BabyError::GcFailed(format!(
"pack-refs failed: {}",
String::from_utf8_lossy(&pr_out.stderr).trim()
)));
}
Ok(PruneResult {
packed_refs: helper::parse_pack_refs_output(&pr_out.stdout),
})
}
}
fn _path_marker(_p: &Path) {}

337
src/command/cmd.rs Normal file
View File

@ -0,0 +1,337 @@
use crate::command::env::Env;
use crate::command::error::{CmdError, CmdResult};
use std::fmt::{Debug, Display, Formatter};
use std::path::PathBuf;
use std::process::{ExitStatus, Stdio};
use time::UtcDateTime;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::process::{Child, Command as TokioCommand};
pub struct Cmd {
pub caller_info: String,
pub prog: String,
pub args: Vec<String>,
pub(crate) config_args: Vec<String>,
pub dir: PathBuf,
pub cmd: Option<Child>,
pub start: UtcDateTime,
pub env: Env,
pub stdin: Box<dyn AsyncWrite + Unpin + Send + Sync>,
pub stdout: Box<dyn AsyncRead + Unpin + Send + Sync>,
pub stderr: Box<dyn AsyncRead + Unpin + Send + Sync>,
pub timeout: Option<u64>,
pub output_cap: usize,
}
pub const DEFAULT_OUTPUT_CAP_BYTES: usize = 64 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct CmdOutput {
pub status: ExitStatus,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
impl Clone for Cmd {
fn clone(&self) -> Self {
Self {
caller_info: self.caller_info.clone(),
prog: self.prog.clone(),
args: self.args.clone(),
config_args: self.config_args.clone(),
dir: self.dir.clone(),
cmd: None,
start: self.start,
env: self.env.clone(),
stdin: Box::new(tokio::io::sink()),
stdout: Box::new(tokio::io::empty()),
stderr: Box::new(tokio::io::empty()),
timeout: self.timeout,
output_cap: self.output_cap,
}
}
}
impl Debug for Cmd {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Cmd")
.field("caller_info", &self.caller_info)
.field("prog", &self.prog)
.field("args", &self.args)
.field("config_args", &self.config_args)
.field("dir", &self.dir)
.field("start", &self.start)
.field("env", &self.env)
.field("timeout", &self.timeout)
.field("cmd", &self.cmd.as_ref().map(|_| "<running>"))
.finish_non_exhaustive()
}
}
impl Display for Cmd {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let mut parts: Vec<&str> = Vec::with_capacity(1 + self.config_args.len() + self.args.len());
parts.push(&self.prog);
for a in &self.config_args {
parts.push(a);
}
for a in &self.args {
parts.push(a);
}
write!(f, "{}", parts.join(" "))
}
}
impl Cmd {
pub fn new(
caller_info: impl Into<String>,
prog: impl Into<String>,
args: Vec<String>,
config_args: Vec<String>,
dir: PathBuf,
env: impl Into<Env>,
timeout: Option<u64>,
) -> Self {
Self {
caller_info: caller_info.into(),
prog: prog.into(),
args,
config_args,
dir,
cmd: None,
start: UtcDateTime::now(),
env: env.into(),
stdin: Box::new(tokio::io::sink()),
stdout: Box::new(tokio::io::empty()),
stderr: Box::new(tokio::io::empty()),
timeout,
output_cap: DEFAULT_OUTPUT_CAP_BYTES,
}
}
pub fn build_command(&self) -> TokioCommand {
let mut command = TokioCommand::new(&self.prog);
for cfg in &self.config_args {
command.arg(cfg);
}
for arg in &self.args {
command.arg(arg);
}
command.current_dir(&self.dir);
command.stdin(Stdio::piped());
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
for (k, v) in self.env.iter() {
command.env(k, v);
}
command
}
pub async fn spawn(&mut self) -> CmdResult<()> {
if self.cmd.is_some() {
return Err(CmdError::Custom(format!(
"process `{}` already spawned",
self.prog
)));
}
self.start = UtcDateTime::now();
let mut command = self.build_command();
let mut child = command.spawn().map_err(|source| CmdError::Spawn {
prog: self.prog.clone(),
source,
})?;
let stdin = child
.stdin
.take()
.ok_or_else(|| CmdError::Custom("stdin was not piped".into()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| CmdError::Custom("stdout was not piped".into()))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| CmdError::Custom("stderr was not piped".into()))?;
self.stdin = Box::new(stdin);
self.stdout = Box::new(stdout);
self.stderr = Box::new(stderr);
self.cmd = Some(child);
Ok(())
}
pub async fn feed(&mut self, input: &[u8]) -> CmdResult<()> {
let writer = std::mem::replace(&mut self.stdin, Box::new(tokio::io::sink()));
let mut writer = writer;
writer
.write_all(input)
.await
.map_err(|source| CmdError::Io {
path: self.dir.clone(),
source,
})?;
writer.shutdown().await.map_err(|source| CmdError::Io {
path: self.dir.clone(),
source,
})?;
Ok(())
}
pub async fn wait(&mut self) -> CmdResult<ExitStatus> {
let child = self
.cmd
.as_mut()
.ok_or_else(|| CmdError::Custom("process not spawned yet".into()))?;
let wait_fut = child.wait();
let status = if let Some(secs) = self.timeout {
match tokio::time::timeout(std::time::Duration::from_secs(secs), wait_fut).await {
Ok(res) => res,
Err(_) => {
let _ = child.start_kill();
// Drain stdin/stdout/stderr so pipes don't keep child alive.
self.stdin = Box::new(tokio::io::sink());
self.stdout = Box::new(tokio::io::empty());
self.stderr = Box::new(tokio::io::empty());
// Reap to avoid zombie.
let _ = child.wait().await;
return Err(CmdError::Timeout {
prog: self.prog.clone(),
timeout_ms: secs.saturating_mul(1000),
});
}
}
} else {
wait_fut.await
};
status.map_err(|source| CmdError::Io {
path: self.dir.clone(),
source,
})
}
pub async fn run(&mut self) -> CmdResult<CmdOutput> {
if self.cmd.is_none() {
self.spawn().await?;
}
let stdout = std::mem::replace(&mut self.stdout, Box::new(tokio::io::empty()));
let stderr = std::mem::replace(&mut self.stderr, Box::new(tokio::io::empty()));
let cap = self.output_cap;
let read_out = async move {
let mut reader = stdout;
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await.map(|_| buf)
};
let read_err = async move {
let mut reader = stderr;
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await.map(|_| buf)
};
let wait_fut = self.wait();
let (out_res, err_res, wait_res) = tokio::join!(read_out, read_err, wait_fut);
let stdout = out_res.map_err(|source| CmdError::Io {
path: self.dir.clone(),
source,
})?;
let stderr = err_res.map_err(|source| CmdError::Io {
path: self.dir.clone(),
source,
})?;
let stdout_len = stdout.len() as u64;
let stderr_len = stderr.len() as u64;
if stdout_len > cap as u64 {
return Err(CmdError::PayloadTooLarge {
cap_bytes: cap as u64,
actual_bytes: stdout_len,
});
}
if stderr_len > cap as u64 {
return Err(CmdError::PayloadTooLarge {
cap_bytes: cap as u64,
actual_bytes: stderr_len,
});
}
let status = wait_res?;
if !status.success() {
return Err(CmdError::NonZeroExit {
prog: self.prog.clone(),
args: self.args.clone(),
code: status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
});
}
Ok(CmdOutput {
status,
stdout,
stderr,
})
}
/// Run the command but treat a non-zero exit as a *successful* run whose
/// `CmdOutput.status` carries the failure, instead of returning
/// `CmdError::NonZeroExit`.
///
/// Use this when the caller wants to inspect exit codes / stderr itself
/// (e.g. classifying "does not exist" vs real errors).
pub async fn run_soft(&mut self) -> CmdResult<CmdOutput> {
match self.run().await {
Ok(out) => Ok(out),
Err(CmdError::NonZeroExit { code, stderr, .. }) => {
#[cfg(unix)]
let status = {
use std::os::unix::process::ExitStatusExt;
ExitStatus::from_raw(code << 8)
};
#[cfg(not(unix))]
let status = {
use std::os::windows::process::ExitStatusExt;
ExitStatus::from_raw(code as u32)
};
Ok(CmdOutput {
status,
stdout: Vec::new(),
stderr: stderr.into_bytes(),
})
}
Err(e) => Err(e),
}
}
pub async fn kill(&mut self) -> CmdResult<()> {
if let Some(child) = self.cmd.as_mut() {
child.start_kill().map_err(|source| CmdError::Io {
path: self.dir.clone(),
source,
})?;
}
Ok(())
}
pub fn try_wait(&mut self) -> CmdResult<Option<ExitStatus>> {
let child = self
.cmd
.as_mut()
.ok_or_else(|| CmdError::Custom("process not spawned yet".into()))?;
child.try_wait().map_err(|source| CmdError::Io {
path: self.dir.clone(),
source,
})
}
pub fn is_running(&self) -> bool {
self.cmd.is_some()
}
#[must_use]
pub fn with_dir(&mut self, dir: PathBuf) -> Self {
self.dir = dir;
self.clone()
}
}

11
src/command/context.rs Normal file
View File

@ -0,0 +1,11 @@
pub trait GitPipeContext {
fn cancel_pipe(&mut self);
fn current_index(&self) -> Option<usize> {
None
}
fn on_command_finished(&mut self, _index: usize) {}
fn on_progress(&mut self, _index: usize, _bytes: u64) {}
}

159
src/command/env.rs Normal file
View File

@ -0,0 +1,159 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::fmt;
#[derive(Default, Clone)]
pub struct Env {
pairs: Vec<(String, String)>,
}
impl Env {
pub fn new() -> Self {
Self::default()
}
pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.pairs.push((key.into(), value.into()));
self
}
pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
let key = key.into();
self.pairs.retain(|(k, _)| k != &key);
self.pairs.push((key, value.into()));
}
pub fn unset(&mut self, key: &str) {
self.pairs.retain(|(k, _)| k != key);
}
pub fn get(&self, key: &str) -> Option<&str> {
self.pairs
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
pub fn as_pairs(&self) -> &[(String, String)] {
&self.pairs
}
pub fn into_pairs(self) -> Vec<(String, String)> {
self.pairs
}
pub fn iter(&self) -> std::slice::Iter<'_, (String, String)> {
self.pairs.iter()
}
pub fn len(&self) -> usize {
self.pairs.len()
}
pub fn is_empty(&self) -> bool {
self.pairs.is_empty()
}
pub fn append(&mut self, other: &Env) {
for (k, v) in other.iter() {
self.set(k.clone(), v.clone());
}
}
pub fn from_current_env() -> Self {
Self {
pairs: std::env::vars().collect(),
}
}
pub fn with_current_env(mut self) -> Self {
for (k, v) in std::env::vars() {
self.set(k, v);
}
self
}
pub fn overlay_current_env(mut self) -> Self {
let existing: HashSet<String> = self.pairs.iter().map(|(k, _)| k.clone()).collect();
for (k, v) in std::env::vars() {
if !existing.contains(&k) {
self.pairs.push((k, v));
}
}
self
}
}
impl fmt::Debug for Env {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let entries: Vec<(&str, String)> = self
.pairs
.iter()
.map(|(k, v)| (k.as_str(), masked_value(k, v)))
.collect();
f.debug_map()
.entries(entries.iter().map(|(k, v)| (*k, v)))
.finish()
}
}
fn is_sensitive_key(key: &str) -> bool {
let lower = key.to_ascii_lowercase();
let markers = [
"token",
"secret",
"password",
"passwd",
"apikey",
"api_key",
"credential",
"authorization",
"private_key",
"session",
"access_key",
"secret_key",
];
markers.iter().any(|m| lower.contains(m))
}
fn masked_value(key: &str, value: &str) -> String {
if is_sensitive_key(key) && !value.is_empty() {
"***REDACTED***".to_string()
} else {
value.to_string()
}
}
impl fmt::Display for Env {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, (k, v)) in self.pairs.iter().enumerate() {
if i > 0 {
f.write_str(" ")?;
}
write!(f, "{}={}", k, masked_value(k, v))?;
}
Ok(())
}
}
impl From<HashMap<String, String>> for Env {
fn from(map: HashMap<String, String>) -> Self {
Self {
pairs: map.into_iter().collect(),
}
}
}
impl From<Vec<(String, String)>> for Env {
fn from(pairs: Vec<(String, String)>) -> Self {
Self { pairs }
}
}
impl FromIterator<(String, String)> for Env {
fn from_iter<T: IntoIterator<Item = (String, String)>>(iter: T) -> Self {
Self {
pairs: iter.into_iter().collect(),
}
}
}

205
src/command/error.rs Normal file
View File

@ -0,0 +1,205 @@
use std::fmt;
use std::path::PathBuf;
#[derive(Debug)]
pub enum CmdError {
Spawn {
prog: String,
source: std::io::Error,
},
Io {
path: PathBuf,
source: std::io::Error,
},
NonZeroExit {
prog: String,
args: Vec<String>,
code: i32,
stderr: String,
},
Timeout {
prog: String,
timeout_ms: u64,
},
InvalidArgs(String),
Cancelled,
Pipe(String),
PayloadTooLarge {
cap_bytes: u64,
actual_bytes: u64,
},
Custom(String),
}
impl fmt::Display for CmdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Spawn { prog, source } => {
write!(f, "failed to spawn `{}`: {}", prog, source)
}
Self::Io { path, source } => {
write!(f, "io error at `{}`: {}", path.display(), source)
}
Self::NonZeroExit {
prog,
args,
code,
stderr,
} => {
let argv_summary = redact_argv(prog, args);
write!(
f,
"`{} {}` exited with code {}: {}",
prog,
argv_summary,
code,
stderr.trim()
)
}
Self::Timeout { prog, timeout_ms } => {
write!(f, "`{}` timed out after {}ms", prog, timeout_ms)
}
Self::InvalidArgs(msg) => write!(f, "invalid args: {}", msg),
Self::Cancelled => write!(f, "command was cancelled"),
Self::Pipe(msg) => write!(f, "pipe error: {}", msg),
Self::PayloadTooLarge {
cap_bytes,
actual_bytes,
} => write!(
f,
"output exceeds cap: {} bytes > {} bytes cap",
actual_bytes, cap_bytes
),
Self::Custom(msg) => write!(f, "{}", msg),
}
}
}
impl std::error::Error for CmdError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Spawn { source, .. } | Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
impl From<std::io::Error> for CmdError {
fn from(source: std::io::Error) -> Self {
Self::Io {
path: PathBuf::new(),
source,
}
}
}
pub type CmdResult<T> = std::result::Result<T, CmdError>;
const REDACTED: &str = "***REDACTED***";
fn redact_argv(prog: &str, args: &[String]) -> String {
let mut out = String::new();
out.push_str(prog);
let mut redact_next = false;
for (idx, arg) in args.iter().enumerate() {
out.push(' ');
if redact_next {
out.push_str(REDACTED);
redact_next = false;
continue;
}
if SENSITIVE_FLAGS
.iter()
.any(|flag| arg == *flag || arg.starts_with(flag))
{
let label = if arg.contains('=') {
arg.split_once('=').map(|(k, _)| k).unwrap_or(arg)
} else {
arg.as_str()
};
out.push_str(label);
out.push('=');
out.push_str(REDACTED);
continue;
}
if URL_VALUE_FLAGS
.iter()
.any(|flag| arg == *flag || arg.starts_with(flag))
{
let label = if arg.contains('=') {
arg.split_once('=').map(|(k, _)| k).unwrap_or(arg)
} else {
arg.as_str()
};
out.push_str(label);
if idx + 1 < args.len() && looks_like_url(&args[idx + 1]) {
out.push(' ');
out.push_str(REDACTED);
let _ = arg;
continue;
}
out.push_str(" <value>");
continue;
}
if looks_like_url(arg) {
out.push_str(REDACTED);
continue;
}
if idx == args.len() - 1 && redact_next_marker(prog, arg) {
redact_next = true;
}
out.push_str(arg);
}
out
}
fn looks_like_url(s: &str) -> bool {
if s.starts_with("http://")
|| s.starts_with("https://")
|| s.starts_with("ssh://")
|| s.starts_with("git://")
|| s.starts_with("file://")
{
true
} else {
s.contains("://") && s.contains('@')
}
}
fn redact_next_marker(prog: &str, arg: &str) -> bool {
if prog != "git" {
return false;
}
matches!(
arg,
"credential.helper"
| "credential.username"
| "credential.password"
| "http.extraheader"
| "core.sshcommand"
| "core.gitproxy"
| "url.<base>.insteadof"
| "include.path"
)
}
static SENSITIVE_FLAGS: &[&str] = &[
"--upload-pack=",
"--receive-pack=",
"-c",
"--config=",
"--config-env=",
"-S",
"--gpg-sign=",
"--key-id=",
"--ssh-command=",
"--askpass=",
];
static URL_VALUE_FLAGS: &[&str] = &[
"remote.add",
"remote set-url",
"remote prune",
"--exec=",
"--uploadarchive=",
];

5
src/command/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod cmd;
pub mod context;
pub mod env;
pub mod error;
pub mod pipe;

122
src/command/pipe.rs Normal file
View File

@ -0,0 +1,122 @@
use crate::command::cmd::Cmd;
use crate::command::context::GitPipeContext;
use std::fmt;
use std::path::PathBuf;
#[derive(Clone)]
pub struct Pipe {
pub name: String,
pub cmds: Vec<Cmd>,
pub dir: PathBuf,
pub timeout: Option<u64>,
pub cancelled: bool,
}
impl Pipe {
pub fn new(name: impl Into<String>, dir: PathBuf) -> Self {
Self {
name: name.into(),
cmds: Vec::new(),
dir,
timeout: None,
cancelled: false,
}
}
/// Whether new subprocesses may still be spawned on this pipe.
/// Once `cancel_pipe()` has been called this returns `false` until `reset()`.
pub fn spawn_allowed(&self) -> bool {
!self.cancelled
}
/// Re-arm a cancelled pipe so new commands may be scheduled again.
/// Pre-existing children that were killed during `cancel_pipe` are kept in
/// `cmds` for audit but must not be reused.
pub fn reset(&mut self) {
self.cancelled = false;
}
pub fn push(mut self, cmd: Cmd) -> Self {
self.cmds.push(cmd);
self
}
pub fn extend<I: IntoIterator<Item = Cmd>>(mut self, cmds: I) -> Self {
self.cmds.extend(cmds);
self
}
pub fn with_timeout(mut self, timeout_ms: u64) -> Self {
self.timeout = Some(timeout_ms);
self
}
pub fn with_dir(mut self, dir: PathBuf) -> Self {
self.dir = dir;
self
}
pub fn len(&self) -> usize {
self.cmds.len()
}
pub fn is_empty(&self) -> bool {
self.cmds.is_empty()
}
pub fn is_cancelled(&self) -> bool {
self.cancelled
}
pub fn first(&self) -> Option<&Cmd> {
self.cmds.first()
}
pub fn last(&self) -> Option<&Cmd> {
self.cmds.last()
}
}
impl fmt::Debug for Pipe {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Pipe")
.field("name", &self.name)
.field("cmds", &self.cmds.len())
.field("dir", &self.dir)
.field("timeout", &self.timeout)
.field("cancelled", &self.cancelled)
.finish()
}
}
impl fmt::Display for Pipe {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.cmds.is_empty() {
return write!(f, "{}:<empty>", self.name);
}
let parts: Vec<String> = self.cmds.iter().map(|c| format!("{c}")).collect();
write!(f, "{}: {}", self.name, parts.join(" | "))
}
}
impl GitPipeContext for Pipe {
fn cancel_pipe(&mut self) {
self.cancelled = true;
for cmd in &mut self.cmds {
if let Some(mut child) = cmd.cmd.take() {
let _ = child.start_kill();
tokio::spawn(async move {
let _ = child.wait().await;
});
}
}
}
fn current_index(&self) -> Option<usize> {
if self.cancelled || self.cmds.is_empty() {
None
} else {
Some(self.cmds.len().saturating_sub(1))
}
}
}

578
src/commit/helper.rs Normal file
View File

@ -0,0 +1,578 @@
use std::path::{Path, PathBuf};
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::command::error::CmdError;
use crate::commit::types::{
CommitInfo, CommitStats, ListCommitsOptions, Signature, TreeEntry, TreeEntryMode,
};
use crate::error::BabyError;
use crate::share;
pub const LOG_FORMAT: &str = "%H%x00%T%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%s%x00%b";
pub const CALLER: &str = "gitbaby::commit";
pub struct StderrCtx<'a> {
pub revision: &'a str,
pub path: Option<&'a Path>,
}
pub fn validate_revision(rev: &str) -> Result<(), BabyError> {
share::validate_revision_non_empty(rev)
}
pub fn validate_path_local_no_escape(path: &Path) -> Result<(), BabyError> {
share::validate_local_no_escape(path)
}
pub fn validate_list_commits_options(opts: &ListCommitsOptions) -> Result<(), BabyError> {
validate_revision(&opts.revision)?;
if let Some(p) = opts.path.as_deref() {
validate_path_local_no_escape(p)?;
}
Ok(())
}
pub fn build_env(alternates: &[PathBuf]) -> Env {
share::build_env_safe(alternates)
}
pub fn build_get_commit_cmd(dir: &Path, env: Env, rev: &str) -> Cmd {
let args = vec!["cat-file".to_string(), "-p".to_string(), rev.to_string()];
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_rev_parse_cmd(dir: &Path, env: Env, rev: &str) -> Cmd {
let args = vec![
"rev-parse".to_string(),
"--verify".to_string(),
"--end-of-options".to_string(),
format!("{}^{{commit}}", rev),
];
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_is_ancestor_cmd(dir: &Path, env: Env, ancestor: &str, descendant: &str) -> Cmd {
let args = vec![
"merge-base".to_string(),
"--is-ancestor".to_string(),
ancestor.to_string(),
descendant.to_string(),
];
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_list_commits_cmd(dir: &Path, env: Env, opts: &ListCommitsOptions) -> Cmd {
let mut args: Vec<String> = Vec::with_capacity(8);
args.push("-c".to_string());
args.push("core.quotepath=off".to_string());
args.push("log".to_string());
args.push("-z".to_string());
args.push(format!("--format={}", LOG_FORMAT));
if let Some(n) = opts.max_count {
args.push(format!("-n{}", n));
}
if let Some(s) = opts.since.as_deref() {
args.push(format!("--since={}", s));
}
if let Some(u) = opts.until.as_deref() {
args.push(format!("--until={}", u));
}
if opts.first_parent {
args.push("--first-parent".to_string());
}
if !opts.revision.is_empty() {
args.push(opts.revision.clone());
}
if let Some(p) = opts.path.as_deref() {
args.push("--".to_string());
args.push(p.to_string_lossy().into_owned());
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_commit_stats_cmd(dir: &Path, env: Env, rev: &str, path: Option<&Path>) -> Cmd {
let mut args: Vec<String> = Vec::with_capacity(4);
args.push("diff".to_string());
args.push("--shortstat".to_string());
args.push(format!("{}^!", rev));
if let Some(p) = path {
args.push("--".to_string());
args.push(p.to_string_lossy().into_owned());
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_tree_entries_cmd(dir: &Path, env: Env, rev: &str, path: &Path) -> Cmd {
let args = vec![
"ls-tree".to_string(),
"-l".to_string(),
rev.to_string(),
"--".to_string(),
path.to_string_lossy().into_owned(),
];
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_last_commit_for_path_cmd(dir: &Path, env: Env, rev: &str, path: &Path) -> Cmd {
let args = vec![
"-c".to_string(),
"core.quotepath=off".to_string(),
"log".to_string(),
"-1".to_string(),
format!("--format={}", LOG_FORMAT),
rev.to_string(),
"--".to_string(),
path.to_string_lossy().into_owned(),
];
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn classify_stderr(stderr: &str, ctx: &StderrCtx<'_>) -> BabyError {
let trimmed = stderr.trim();
if let Some(rest) = trimmed.strip_prefix("fatal: ambiguous argument '")
&& let Some(needle) =
rest.strip_suffix("': unknown revision or path not in the working tree.")
{
return BabyError::RevisionNotFound(needle.to_string());
}
if let Some(rest) = trimmed.strip_prefix("fatal: Not a tree: ") {
return BabyError::NotATree {
revision: rest.trim().to_string(),
path: ctx.path.map_or(PathBuf::new(), |p| p.to_path_buf()),
};
}
if trimmed.contains("bad revision") || trimmed.contains("unknown revision") {
let mut iter = trimmed
.split_whitespace()
.filter(|w| !w.starts_with("fatal:"));
let rev = match iter.next() {
Some(w) => w.trim_matches('\'').to_string(),
None => String::new(),
};
if rev.is_empty() {
return BabyError::RevisionNotFound(ctx.revision.to_string());
}
return BabyError::RevisionNotFound(rev);
}
if let Some(rest) = trimmed.strip_prefix("fatal: not a tree object ") {
return BabyError::NotATree {
revision: rest.trim().to_string(),
path: ctx.path.map_or(PathBuf::new(), |p| p.to_path_buf()),
};
}
if let Some(rest) = trimmed.strip_prefix("fatal: not a commit: ") {
return BabyError::RevisionInvalid(rest.trim().to_string());
}
BabyError::Spawn {
prog: "git".to_string(),
source: std::io::Error::other("git non-zero exit"),
stderr: trimmed.to_string(),
}
}
pub fn classify_cmd_error(err: CmdError, ctx: &StderrCtx<'_>) -> BabyError {
match err {
CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, ctx),
CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
other => BabyError::from(other),
}
}
pub fn facade_error<E: std::error::Error + Send + Sync + 'static>(e: E) -> BabyError {
share::facade_error(e)
}
pub fn parse_signature_line(line: &str) -> Result<Signature, BabyError> {
let lt_idx = line.rfind(" <").ok_or_else(|| BabyError::CommitParse {
line_number: 0,
message: format!("missing `<` in signature: {}", line),
})?;
let gt_idx = line.rfind('>').ok_or_else(|| BabyError::CommitParse {
line_number: 0,
message: format!("missing `>` in signature: {}", line),
})?;
if gt_idx <= lt_idx {
return Err(BabyError::CommitParse {
line_number: 0,
message: format!("malformed signature delimiters: {}", line),
});
}
let name_raw = line[..lt_idx].trim().to_string();
let name = if let Some(stripped) = name_raw.strip_prefix('"').and_then(|s| s.strip_suffix('"'))
{
unescape_quoted_name(stripped)
} else {
name_raw
};
let email = line[lt_idx + 2..gt_idx].trim().to_string();
let tail = line[gt_idx + 1..].trim();
let mut parts = tail.split_whitespace();
let ts_str = parts.next().ok_or_else(|| BabyError::CommitParse {
line_number: 0,
message: format!("missing timestamp in signature: {}", line),
})?;
let tz_str = parts.next().ok_or_else(|| BabyError::CommitParse {
line_number: 0,
message: format!("missing timezone in signature: {}", line),
})?;
let unix: i64 = ts_str
.parse()
.map_err(|_| BabyError::InvalidTimestamp(ts_str.to_string()))?;
let offset = parse_offset(tz_str)?;
let time = time::OffsetDateTime::from_unix_timestamp(unix)
.map_err(|_| BabyError::InvalidTimestamp(ts_str.to_string()))?
.to_offset(offset);
Ok(Signature { name, email, time })
}
fn unescape_quoted_name(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(next) = chars.next() {
match next {
'n' => out.push('\n'),
't' => out.push('\t'),
'\\' => out.push('\\'),
'"' => out.push('"'),
other => {
out.push('\\');
out.push(other);
}
}
} else {
out.push('\\');
}
} else {
out.push(c);
}
}
out
}
fn parse_offset(s: &str) -> Result<time::UtcOffset, BabyError> {
let bytes = s.as_bytes();
if bytes.len() != 5 || (bytes[0] != b'+' && bytes[0] != b'-') {
return Err(BabyError::InvalidTimestamp(format!("offset {}", s)));
}
let sign = if bytes[0] == b'-' { -1 } else { 1 };
let hours: i32 = std::str::from_utf8(&bytes[1..3])
.map_err(|_| BabyError::InvalidTimestamp(s.to_string()))?
.parse()
.map_err(|_| BabyError::InvalidTimestamp(s.to_string()))?;
let mins: i32 = std::str::from_utf8(&bytes[3..5])
.map_err(|_| BabyError::InvalidTimestamp(s.to_string()))?
.parse()
.map_err(|_| BabyError::InvalidTimestamp(s.to_string()))?;
let total_seconds = sign * (hours * 3600 + mins * 60);
time::UtcOffset::from_whole_seconds(total_seconds)
.map_err(|_| BabyError::InvalidTimestamp(s.to_string()))
}
pub fn parse_commit_porcelain(id: gix::ObjectId, bytes: &[u8]) -> Result<CommitInfo, BabyError> {
let text = std::str::from_utf8(bytes).map_err(|source| BabyError::CommitParse {
line_number: 0,
message: format!("commit porcelain is not utf-8: {}", source),
})?;
let mut tree_id: Option<gix::ObjectId> = None;
let mut parents: Vec<gix::ObjectId> = Vec::new();
let mut author: Option<Signature> = None;
let mut committer: Option<Signature> = None;
let mut gpg_signature: Option<String> = None;
let mut message_lines: Vec<String> = Vec::new();
let mut in_message = false;
for (idx, line) in text.lines().enumerate() {
let line_no = idx as u64 + 1;
if in_message {
message_lines.push(line.to_string());
continue;
}
if line.is_empty() {
in_message = true;
continue;
}
if let Some(rest) = line.strip_prefix("tree ") {
tree_id = Some(parse_object_id_field(rest, line_no, "tree")?);
continue;
}
if let Some(rest) = line.strip_prefix("parent ") {
parents.push(parse_object_id_field(rest, line_no, "parent")?);
continue;
}
if let Some(rest) = line.strip_prefix("author ") {
author = Some(
parse_signature_line(rest).map_err(|_| BabyError::CommitParse {
line_number: line_no,
message: format!("bad author: {}", rest),
})?,
);
continue;
}
if let Some(rest) = line.strip_prefix("committer ") {
committer = Some(
parse_signature_line(rest).map_err(|_| BabyError::CommitParse {
line_number: line_no,
message: format!("bad committer: {}", rest),
})?,
);
continue;
}
if let Some(rest) = line.strip_prefix("gpgsig ") {
gpg_signature = Some(rest.trim_start().trim_end_matches('\r').to_string());
continue;
}
if line.starts_with(' ') && gpg_signature.is_some() {
if let Some(sig) = gpg_signature.as_mut() {
sig.push('\n');
sig.push_str(line.trim_start());
}
continue;
}
return Err(BabyError::CommitParse {
line_number: line_no,
message: format!("unexpected header line: {}", line),
});
}
let tree_id = tree_id.ok_or_else(|| BabyError::CommitParse {
line_number: 0,
message: "commit porcelain missing tree".into(),
})?;
let author = author.ok_or_else(|| BabyError::CommitParse {
line_number: 0,
message: "commit porcelain missing author".into(),
})?;
let committer = committer.ok_or_else(|| BabyError::CommitParse {
line_number: 0,
message: "commit porcelain missing committer".into(),
})?;
let message = if message_lines.is_empty() {
String::new()
} else {
let trimmed_start = message_lines
.iter()
.position(|l| !l.is_empty())
.unwrap_or(message_lines.len());
let mut lines = message_lines[trimmed_start..].to_vec();
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
lines.join("\n")
};
Ok(CommitInfo {
id,
tree_id,
parents,
author,
committer,
message,
gpg_signature,
})
}
fn parse_object_id_field(s: &str, line_no: u64, field: &str) -> Result<gix::ObjectId, BabyError> {
let hex = s.trim();
gix::ObjectId::from_hex(hex.as_bytes()).map_err(|source| BabyError::CommitParse {
line_number: line_no,
message: format!("bad {} oid `{}`: {}", field, hex, source),
})
}
pub fn parse_log_porcelain(bytes: &[u8]) -> Result<Vec<CommitInfo>, BabyError> {
let mut commits = Vec::new();
for record in bytes.split(|b| *b == 0) {
if record.is_empty() {
continue;
}
let fields: Vec<&[u8]> = record.split(|b| *b == 0).collect();
if fields.len() < 10 {
return Err(BabyError::CommitParse {
line_number: 0,
message: format!("log record has {} fields, expected 10", fields.len()),
});
}
let id = gix::ObjectId::from_hex(fields[0]).map_err(|source| BabyError::CommitParse {
line_number: 0,
message: format!("bad id: {}", source),
})?;
let tree_id =
gix::ObjectId::from_hex(fields[1]).map_err(|source| BabyError::CommitParse {
line_number: 0,
message: format!("bad tree: {}", source),
})?;
let mut parents = Vec::new();
if !fields[2].is_empty() {
for hex in fields[2].split(|b| *b == b' ') {
if hex.is_empty() {
continue;
}
let p = gix::ObjectId::from_hex(hex).map_err(|source| BabyError::CommitParse {
line_number: 0,
message: format!("bad parent: {}", source),
})?;
parents.push(p);
}
}
let author = parse_signature_line(std::str::from_utf8(fields[3]).map_err(|source| {
BabyError::CommitParse {
line_number: 0,
message: format!("author not utf-8: {}", source),
}
})?)?;
let author_email = std::str::from_utf8(fields[4])
.map_err(|source| BabyError::CommitParse {
line_number: 0,
message: format!("author email not utf-8: {}", source),
})?
.to_string();
if author_email != author.email {
return Err(BabyError::CommitParse {
line_number: 0,
message: format!(
"author email mismatch: `{}` vs `{}`",
author_email, author.email
),
});
}
let committer =
parse_signature_line(std::str::from_utf8(fields[6]).map_err(|source| {
BabyError::CommitParse {
line_number: 0,
message: format!("committer not utf-8: {}", source),
}
})?)?;
let committer_email = std::str::from_utf8(fields[7])
.map_err(|source| BabyError::CommitParse {
line_number: 0,
message: format!("committer email not utf-8: {}", source),
})?
.to_string();
if committer_email != committer.email {
return Err(BabyError::CommitParse {
line_number: 0,
message: format!(
"committer email mismatch: `{}` vs `{}`",
committer_email, committer.email
),
});
}
let subject = std::str::from_utf8(fields[8])
.map_err(|source| BabyError::CommitParse {
line_number: 0,
message: format!("subject not utf-8: {}", source),
})?
.to_string();
let body = std::str::from_utf8(fields[9])
.map_err(|source| BabyError::CommitParse {
line_number: 0,
message: format!("body not utf-8: {}", source),
})?
.trim_end_matches('\n')
.to_string();
let message = if body.is_empty() {
subject
} else {
format!("{}\n\n{}", subject, body)
};
commits.push(CommitInfo {
id,
tree_id,
parents,
author,
committer,
message,
gpg_signature: None,
});
}
Ok(commits)
}
pub fn parse_tree_entries(bytes: &[u8]) -> Result<Vec<TreeEntry>, BabyError> {
let mut out = Vec::new();
for (idx, line) in bytes.split(|b| *b == b'\n').enumerate() {
if line.is_empty() {
continue;
}
let line_str = std::str::from_utf8(line).map_err(|source| BabyError::CommitParse {
line_number: idx as u64 + 1,
message: format!("tree entry not utf-8: {}", source),
})?;
let mut parts = line_str.splitn(3, ' ');
let mode_str = parts.next().ok_or_else(|| BabyError::CommitParse {
line_number: idx as u64 + 1,
message: format!("tree entry missing mode: {}", line_str),
})?;
let mode =
TreeEntryMode::from_octal_str(mode_str).ok_or_else(|| BabyError::CommitParse {
line_number: idx as u64 + 1,
message: format!("unknown tree mode: {}", mode_str),
})?;
let rest = parts.next().ok_or_else(|| BabyError::CommitParse {
line_number: idx as u64 + 1,
message: format!("tree entry missing type: {}", line_str),
})?;
let oid_hex = rest
.split(' ')
.next()
.ok_or_else(|| BabyError::CommitParse {
line_number: idx as u64 + 1,
message: format!("tree entry missing oid: {}", line_str),
})?;
let oid = gix::ObjectId::from_hex(oid_hex.as_bytes()).map_err(|source| {
BabyError::CommitParse {
line_number: idx as u64 + 1,
message: format!("bad tree oid `{}`: {}", oid_hex, source),
}
})?;
let tail = parts.next().ok_or_else(|| BabyError::CommitParse {
line_number: idx as u64 + 1,
message: format!("tree entry missing tail: {}", line_str),
})?;
let (name, size) = if let Some((n, s)) = tail.split_once('\t') {
(n.to_string(), s.parse::<u64>().ok())
} else {
(tail.to_string(), None)
};
out.push(TreeEntry {
oid,
name,
mode,
size,
});
}
Ok(out)
}
pub fn parse_shortstat(bytes: &[u8]) -> Result<CommitStats, BabyError> {
let text = std::str::from_utf8(bytes).map_err(|source| BabyError::CommitParse {
line_number: 0,
message: format!("shortstat not utf-8: {}", source),
})?;
let mut stats = CommitStats::default();
for part in text.split_whitespace() {
if let Some(rest) = part.strip_suffix(')') {
if rest.contains("(+)") {
let n: u64 = rest.trim_end_matches("(+)").parse().map_err(|source| {
BabyError::CommitParse {
line_number: 0,
message: format!("bad additions number `{}`: {}", part, source),
}
})?;
stats.additions = n;
continue;
}
if rest.contains("(-)") {
let n: u64 = rest.trim_end_matches("(-)").parse().map_err(|source| {
BabyError::CommitParse {
line_number: 0,
message: format!("bad deletions number `{}`: {}", part, source),
}
})?;
stats.deletions = n;
continue;
}
}
}
Ok(stats)
}

5
src/commit/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::{CommitInfo, CommitStats, ListCommitsOptions, Signature, TreeEntry, TreeEntryMode};

99
src/commit/types.rs Normal file
View File

@ -0,0 +1,99 @@
use std::fmt;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TreeEntryMode {
Blob,
Exec,
Symlink,
Tree,
Submodule,
Commit,
}
impl TreeEntryMode {
pub fn from_octal_str(s: &str) -> Option<Self> {
let kind = u32::from_str_radix(s, 8).ok()?;
let file_kind = kind & 0o170000;
match file_kind {
0o040000 => return Some(Self::Tree),
0o160000 => return Some(Self::Commit),
_ => {}
}
match kind {
0o100644 => Some(Self::Blob),
0o100755 => Some(Self::Exec),
0o120000 => Some(Self::Symlink),
_ => None,
}
}
}
impl fmt::Display for TreeEntryMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Blob => "blob",
Self::Exec => "exec",
Self::Symlink => "symlink",
Self::Tree => "tree",
Self::Submodule => "submodule",
Self::Commit => "commit",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Signature {
pub name: String,
pub email: String,
pub time: time::OffsetDateTime,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitInfo {
pub id: gix::ObjectId,
pub tree_id: gix::ObjectId,
pub parents: Vec<gix::ObjectId>,
pub author: Signature,
pub committer: Signature,
pub message: String,
pub gpg_signature: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CommitStats {
pub additions: u64,
pub deletions: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeEntry {
pub oid: gix::ObjectId,
pub name: String,
pub mode: TreeEntryMode,
pub size: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListCommitsOptions {
pub revision: String,
pub path: Option<PathBuf>,
pub max_count: Option<u32>,
pub since: Option<String>,
pub until: Option<String>,
pub first_parent: bool,
}
impl Default for ListCommitsOptions {
fn default() -> Self {
Self {
revision: "HEAD".to_string(),
path: None,
max_count: None,
since: None,
until: None,
first_parent: false,
}
}
}

238
src/commit/usecase.rs Normal file
View File

@ -0,0 +1,238 @@
use std::path::Path;
use crate::GitBaby;
use crate::commit::helper;
use crate::commit::types::{CommitInfo, CommitStats, ListCommitsOptions, TreeEntry};
use crate::error::BabyError;
impl GitBaby {
pub async fn get_commit(&self, revision: &str) -> Result<CommitInfo, BabyError> {
helper::validate_revision(revision)?;
let mut cmd = self
.spawn_commit_cmd(revision, |dir, env| {
helper::build_get_commit_cmd(dir, env, revision)
})
.await?;
let ctx = helper::StderrCtx {
revision,
path: None,
};
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, &ctx))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
&ctx,
));
}
let id = self
.rev_parse(revision)
.await
.map_err(helper::facade_error)?;
helper::parse_commit_porcelain(id, &output.stdout)
}
pub async fn rev_parse(&self, revision: &str) -> Result<gix::ObjectId, BabyError> {
helper::validate_revision(revision)?;
let mut cmd = self
.spawn_commit_cmd(revision, |dir, env| {
helper::build_rev_parse_cmd(dir, env, revision)
})
.await?;
let ctx = helper::StderrCtx {
revision,
path: None,
};
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, &ctx))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
&ctx,
));
}
let text = String::from_utf8_lossy(&output.stdout);
let hex = text.trim();
gix::ObjectId::from_hex(hex.as_bytes()).map_err(|source| {
BabyError::Custom(format!(
"rev-parse returned invalid oid `{}`: {}",
hex, source
))
})
}
pub async fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool, BabyError> {
helper::validate_revision(ancestor)?;
helper::validate_revision(descendant)?;
let mut cmd = self
.spawn_commit_cmd(descendant, |dir, env| {
helper::build_is_ancestor_cmd(dir, env, ancestor, descendant)
})
.await?;
let ctx = helper::StderrCtx {
revision: descendant,
path: None,
};
let output = match cmd.run().await {
Ok(o) => o,
Err(CmdError::NonZeroExit { stderr, .. }) => {
if stderr.trim().is_empty() {
return Ok(false);
}
return Err(helper::classify_stderr(&stderr, &ctx));
}
Err(e) => return Err(helper::classify_cmd_error(e, &ctx)),
};
if output.status.success() {
Ok(true)
} else {
Ok(false)
}
}
pub async fn list_commits(
&self,
opts: ListCommitsOptions,
) -> Result<Vec<CommitInfo>, BabyError> {
helper::validate_list_commits_options(&opts)?;
let rev = opts.revision.clone();
let mut cmd = self
.spawn_commit_cmd(&rev, |dir, env| {
helper::build_list_commits_cmd(dir, env, &opts)
})
.await?;
let ctx = helper::StderrCtx {
revision: &rev,
path: opts.path.as_deref(),
};
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, &ctx))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
&ctx,
));
}
helper::parse_log_porcelain(&output.stdout)
}
pub async fn commit_stats(
&self,
revision: &str,
path: Option<&Path>,
) -> Result<CommitStats, BabyError> {
helper::validate_revision(revision)?;
if let Some(p) = path {
helper::validate_path_local_no_escape(p)?;
}
let mut cmd = self
.spawn_commit_cmd(revision, |dir, env| {
helper::build_commit_stats_cmd(dir, env, revision, path)
})
.await?;
let ctx = helper::StderrCtx { revision, path };
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, &ctx))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
&ctx,
));
}
helper::parse_shortstat(&output.stdout)
}
pub async fn get_tree_entries(
&self,
revision: &str,
path: &Path,
) -> Result<Vec<TreeEntry>, BabyError> {
helper::validate_revision(revision)?;
helper::validate_path_local_no_escape(path)?;
let mut cmd = self
.spawn_commit_cmd(revision, |dir, env| {
helper::build_tree_entries_cmd(dir, env, revision, path)
})
.await?;
let ctx = helper::StderrCtx {
revision,
path: Some(path),
};
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, &ctx))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
&ctx,
));
}
helper::parse_tree_entries(&output.stdout)
}
pub async fn last_commit_for_path(
&self,
revision: &str,
path: &Path,
) -> Result<CommitInfo, BabyError> {
helper::validate_revision(revision)?;
helper::validate_path_local_no_escape(path)?;
let mut cmd = self
.spawn_commit_cmd(revision, |dir, env| {
helper::build_last_commit_for_path_cmd(dir, env, revision, path)
})
.await?;
let ctx = helper::StderrCtx {
revision,
path: Some(path),
};
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, &ctx))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
&ctx,
));
}
let mut commits = helper::parse_log_porcelain(&output.stdout)?;
commits
.pop()
.ok_or_else(|| BabyError::RevisionNotFound(revision.to_string()))
}
async fn spawn_commit_cmd<F>(
&self,
rev: &str,
builder: F,
) -> Result<crate::command::cmd::Cmd, BabyError>
where
F: FnOnce(&Path, crate::command::env::Env) -> crate::command::cmd::Cmd,
{
let _ = rev;
let dir = self
.facade
.git_repo_dir()
.await
.map_err(helper::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(helper::facade_error)?;
let env = helper::build_env(&alternates);
Ok(builder(&dir, env))
}
}
use crate::command::error::CmdError;

122
src/compare/helper.rs Normal file
View File

@ -0,0 +1,122 @@
use std::path::Path;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::compare::types::DivergeObject;
use crate::error::BabyError;
pub fn build_diverging_commits_cmd(
dir: &Path,
env: Env,
base: &str,
target: &str,
) -> Result<Cmd, BabyError> {
crate::share::validate_revision(base)?;
crate::share::validate_revision(target)?;
let args = vec![
"rev-list".to_string(),
"--count".to_string(),
"--left-right".to_string(),
"--end-of-options".to_string(),
format!("{}...{}", base, target),
];
Ok(crate::share::git_cmd(
"gitbaby::compare",
dir,
env,
None,
args,
))
}
pub fn build_commit_ids_between_reverse_cmd(
dir: &Path,
env: Env,
start: &str,
end: &str,
not_ref: Option<&str>,
limit: Option<u32>,
) -> Result<Cmd, BabyError> {
crate::share::validate_revision(start)?;
crate::share::validate_revision(end)?;
if let Some(n) = not_ref {
crate::share::validate_revision(n)?;
}
let mut args = vec!["rev-list".to_string()];
if let Some(n) = not_ref {
args.push("--not".to_string());
args.push(n.to_string());
}
if let Some(l) = limit {
args.push("--max-count".to_string());
args.push(l.to_string());
}
args.push("--reverse".to_string());
args.push("--end-of-options".to_string());
args.push(format!("{}..{}", start, end));
Ok(crate::share::git_cmd(
"gitbaby::compare",
dir,
env,
None,
args,
))
}
pub fn classify_cmd_error(err: crate::command::error::CmdError) -> BabyError {
use crate::command::error::CmdError;
match err {
CmdError::NonZeroExit { stderr, .. } => {
if stderr.trim().is_empty() {
BabyError::Custom("rev-list exited non-zero with empty stderr".to_string())
} else {
BabyError::Custom(format!("rev-list failed: {}", stderr.trim()))
}
}
CmdError::Spawn { prog, source } => crate::share::classify_spawn_error(prog, source),
other => BabyError::from(other),
}
}
pub fn parse_left_right_count(output: &[u8]) -> Result<DivergeObject, BabyError> {
let text = String::from_utf8_lossy(output);
let mut parts = text.split_whitespace();
let ahead = match parts.next() {
Some(s) => s.parse::<u32>().map_err(|e| {
BabyError::Custom(format!(
"expected digit before tab in `rev-list --left-right --count`, got `{}`: {}",
text.trim(),
e
))
})?,
None => {
return Err(BabyError::Custom(
"empty `rev-list --left-right --count` output".to_string(),
));
}
};
let behind = match parts.next() {
Some(s) => s.parse::<u32>().map_err(|e| {
BabyError::Custom(format!(
"expected digit after tab in `rev-list --left-right --count`, got `{}`: {}",
text.trim(),
e
))
})?,
None => 0,
};
Ok(DivergeObject { ahead, behind })
}
pub fn parse_rev_list_oids(output: &[u8]) -> Result<Vec<gix::ObjectId>, BabyError> {
let mut oids = Vec::new();
for line in String::from_utf8_lossy(output).lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let oid = crate::share::parse_object_id(trimmed)?;
oids.push(oid);
}
Ok(oids)
}

4
src/compare/mod.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod helper;
pub mod types;
pub use types::DivergeObject;

13
src/compare/types.rs Normal file
View File

@ -0,0 +1,13 @@
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DivergeObject {
pub ahead: u32,
pub behind: u32,
}
impl fmt::Display for DivergeObject {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ahead={}, behind={}", self.ahead, self.behind)
}
}

69
src/compare/usecase.rs Normal file
View File

@ -0,0 +1,69 @@
use crate::compare::helper;
use crate::compare::types::DivergeObject;
use crate::error::BabyError;
use crate::GitBaby;
impl GitBaby {
pub async fn diverging_commits(
&self,
base: &str,
target: &str,
) -> Result<DivergeObject, BabyError> {
share::validate_revision_non_empty(base)?;
share::validate_revision_non_empty(target)?;
let dir = self.facade.git_repo_dir().await.map_err(share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(share::facade_error)?;
let env = share::build_env_safe(&alternates);
let mut cmd = helper::build_diverging_commits_cmd(&dir, env, base, target)?;
let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?;
if !output.status.success() {
return Err(BabyError::Custom(format!(
"git rev-list --left-right failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
helper::parse_left_right_count(&output.stdout)
}
pub async fn commit_ids_between_reverse(
&self,
start: &str,
end: &str,
not_ref: Option<&str>,
limit: Option<u32>,
) -> Result<Vec<gix::ObjectId>, BabyError> {
share::validate_revision_non_empty(start)?;
share::validate_revision_non_empty(end)?;
let dir = self.facade.git_repo_dir().await.map_err(share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(share::facade_error)?;
let env = share::build_env_safe(&alternates);
let mut cmd =
helper::build_commit_ids_between_reverse_cmd(&dir, env, start, end, not_ref, limit)?;
match cmd.run().await {
Ok(output) => {
if !output.status.success() {
return Err(BabyError::Custom(format!(
"git rev-list failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
helper::parse_rev_list_oids(&output.stdout)
}
Err(crate::command::error::CmdError::NonZeroExit { stderr, .. })
if !stderr.trim().is_empty() && not_ref.is_some() =>
{
let _ = stderr;
Ok(Vec::new())
}
Err(e) => Err(helper::classify_cmd_error(e)),
}
}
}

152
src/config/helper.rs Normal file
View File

@ -0,0 +1,152 @@
use std::path::Path;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::config::types::{ConfigEntry, ConfigScope};
use crate::error::BabyError;
fn share_scope(scope: &ConfigScope) -> crate::share::ConfigScope {
match scope.0.as_str() {
"global" => crate::share::ConfigScope::Global,
_ => crate::share::ConfigScope::Local,
}
}
pub fn run_get(dir: &Path, env: Env, scope: &ConfigScope, key: &str) -> Result<Cmd, BabyError> {
crate::share::build_config_cmd(
"gitbaby::config",
dir,
env,
share_scope(scope),
crate::share::ConfigOp::Get,
key,
None,
)
}
pub fn run_get_all(dir: &Path, env: Env, scope: &ConfigScope, key: &str) -> Result<Cmd, BabyError> {
crate::share::build_config_cmd(
"gitbaby::config",
dir,
env,
share_scope(scope),
crate::share::ConfigOp::GetAll,
key,
None,
)
}
pub fn run_get_regexp(
dir: &Path,
env: Env,
scope: &ConfigScope,
pattern: &str,
) -> Result<Cmd, BabyError> {
crate::share::build_config_cmd(
"gitbaby::config",
dir,
env,
share_scope(scope),
crate::share::ConfigOp::GetRegexp,
pattern,
None,
)
}
pub fn run_set(
dir: &Path,
env: Env,
scope: &ConfigScope,
key: &str,
value: &str,
) -> Result<Cmd, BabyError> {
crate::share::build_config_cmd(
"gitbaby::config",
dir,
env,
share_scope(scope),
crate::share::ConfigOp::Set,
key,
Some(value),
)
}
pub fn run_set_if_absent(
dir: &Path,
env: Env,
scope: &ConfigScope,
key: &str,
value: &str,
) -> Result<Cmd, BabyError> {
crate::share::build_config_cmd(
"gitbaby::config",
dir,
env,
share_scope(scope),
crate::share::ConfigOp::SetIfAbsent,
key,
Some(value),
)
}
pub fn run_add(
dir: &Path,
env: Env,
scope: &ConfigScope,
key: &str,
value: &str,
) -> Result<Cmd, BabyError> {
crate::share::build_config_cmd(
"gitbaby::config",
dir,
env,
share_scope(scope),
crate::share::ConfigOp::Add,
key,
Some(value),
)
}
pub fn run_unset_all(
dir: &Path,
env: Env,
scope: &ConfigScope,
key: &str,
) -> Result<Cmd, BabyError> {
crate::share::build_config_cmd(
"gitbaby::config",
dir,
env,
share_scope(scope),
crate::share::ConfigOp::UnsetAll,
key,
None,
)
}
pub fn classify_set_failure(key: &str, stderr: &str) -> BabyError {
BabyError::ConfigSetFailed {
key: key.to_string(),
stderr: stderr.to_string(),
}
}
pub fn classify_get_failure(key: &str, stderr: &str) -> BabyError {
BabyError::ConfigGetFailed {
key: key.to_string(),
stderr: stderr.to_string(),
}
}
pub fn parse_kv_lines(output: &[u8]) -> Vec<ConfigEntry> {
String::from_utf8_lossy(output)
.lines()
.filter_map(|line| {
let (k, v) = line.split_once('=')?;
Some(ConfigEntry {
key: k.to_string(),
value: v.to_string(),
})
})
.collect()
}

5
src/config/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::{ConfigEntry, ConfigScope};

25
src/config/types.rs Normal file
View File

@ -0,0 +1,25 @@
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigEntry {
pub key: String,
pub value: String,
}
impl fmt::Display for ConfigEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}={}", self.key, self.value)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigScope(pub String);
impl ConfigScope {
pub fn global() -> Self {
Self("global".to_string())
}
pub fn local() -> Self {
Self("local".to_string())
}
}

255
src/config/usecase.rs Normal file
View File

@ -0,0 +1,255 @@
use crate::GitBaby;
use crate::config::helper;
use crate::config::types::{ConfigEntry, ConfigScope};
use crate::error::BabyError;
use crate::share;
async fn build_env_for_scope(
gitbaby: &GitBaby,
scope: &ConfigScope,
) -> Result<(std::path::PathBuf, crate::command::env::Env), BabyError> {
let _ = scope;
let dir = gitbaby
.facade
.git_repo_dir()
.await
.map_err(share::facade_error)?;
let alternates = gitbaby
.facade
.git_alternate_object_directories()
.await
.map_err(share::facade_error)?;
let env = share::build_env_safe(&alternates);
Ok((dir, env))
}
impl GitBaby {
pub async fn config_get(&self, key: &str) -> Result<Option<String>, BabyError> {
self.config_get_with_scope(key, &ConfigScope::local()).await
}
pub async fn config_get_global(&self, key: &str) -> Result<Option<String>, BabyError> {
self.config_get_with_scope(key, &ConfigScope::global())
.await
}
pub async fn config_set(&self, key: &str, value: &str) -> Result<(), BabyError> {
self.config_set_with_scope(key, value, &ConfigScope::local())
.await
}
pub async fn config_set_global(&self, key: &str, value: &str) -> Result<(), BabyError> {
self.config_set_with_scope(key, value, &ConfigScope::global())
.await
}
pub async fn config_set_if_absent(&self, key: &str, value: &str) -> Result<(), BabyError> {
self.config_set_if_absent_with_scope(key, value, &ConfigScope::local())
.await
}
pub async fn config_set_if_absent_global(
&self,
key: &str,
value: &str,
) -> Result<(), BabyError> {
self.config_set_if_absent_with_scope(key, value, &ConfigScope::global())
.await
}
pub async fn config_add(&self, key: &str, value: &str) -> Result<(), BabyError> {
self.config_add_with_scope(key, value, &ConfigScope::local())
.await
}
pub async fn config_add_global(&self, key: &str, value: &str) -> Result<(), BabyError> {
self.config_add_with_scope(key, value, &ConfigScope::global())
.await
}
pub async fn config_unset_all(&self, key: &str) -> Result<(), BabyError> {
self.config_unset_all_with_scope(key, &ConfigScope::local())
.await
}
pub async fn config_unset_all_global(&self, key: &str) -> Result<(), BabyError> {
self.config_unset_all_with_scope(key, &ConfigScope::global())
.await
}
pub async fn config_get_all(&self, key: &str) -> Result<Vec<String>, BabyError> {
self.config_get_all_with_scope(key, &ConfigScope::local())
.await
}
pub async fn config_get_all_global(&self, key: &str) -> Result<Vec<String>, BabyError> {
self.config_get_all_with_scope(key, &ConfigScope::global())
.await
}
pub async fn config_get_regexp(&self, pattern: &str) -> Result<Vec<ConfigEntry>, BabyError> {
self.config_get_regexp_with_scope(pattern, &ConfigScope::local())
.await
}
pub async fn config_get_regexp_global(
&self,
pattern: &str,
) -> Result<Vec<ConfigEntry>, BabyError> {
self.config_get_regexp_with_scope(pattern, &ConfigScope::global())
.await
}
pub async fn managed_config_set(&self, key: &str, value: &str) -> Result<(), BabyError> {
self.config_set_with_scope(key, value, &ConfigScope::local())
.await
}
pub async fn managed_config_add(&self, key: &str, value: &str) -> Result<(), BabyError> {
self.config_add_with_scope(key, value, &ConfigScope::local())
.await
}
async fn config_get_with_scope(
&self,
key: &str,
scope: &ConfigScope,
) -> Result<Option<String>, BabyError> {
let (dir, env) = build_env_for_scope(self, scope).await?;
let mut cmd = helper::run_get(&dir, env, scope, key)?;
let output = cmd.run_soft().await.map_err(BabyError::from)?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
return Ok(Some(text));
}
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.code() == Some(1) && stderr.trim().is_empty() {
return Ok(None);
}
if output.status.code() == Some(1) && stderr.contains("key does not exist") {
return Ok(None);
}
Err(helper::classify_get_failure(
key,
&String::from_utf8_lossy(&output.stderr),
))
}
async fn config_set_with_scope(
&self,
key: &str,
value: &str,
scope: &ConfigScope,
) -> Result<(), BabyError> {
let (dir, env) = build_env_for_scope(self, scope).await?;
let mut cmd = helper::run_set(&dir, env, scope, key, value)?;
let output = cmd.run_soft().await.map_err(BabyError::from)?;
if output.status.success() {
return Ok(());
}
Err(helper::classify_set_failure(
key,
&String::from_utf8_lossy(&output.stderr),
))
}
async fn config_set_if_absent_with_scope(
&self,
key: &str,
value: &str,
scope: &ConfigScope,
) -> Result<(), BabyError> {
let (dir, env) = build_env_for_scope(self, scope).await?;
let mut cmd = helper::run_set_if_absent(&dir, env, scope, key, value)?;
let output = cmd.run_soft().await.map_err(BabyError::from)?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.code() == Some(1) && stderr.contains("already exists") {
return Ok(());
}
if output.status.code() == Some(1) && stderr.trim().is_empty() {
return Ok(());
}
Err(helper::classify_set_failure(key, &stderr))
}
async fn config_add_with_scope(
&self,
key: &str,
value: &str,
scope: &ConfigScope,
) -> Result<(), BabyError> {
let (dir, env) = build_env_for_scope(self, scope).await?;
let mut cmd = helper::run_add(&dir, env, scope, key, value)?;
let output = cmd.run_soft().await.map_err(BabyError::from)?;
if output.status.success() {
return Ok(());
}
Err(helper::classify_set_failure(
key,
&String::from_utf8_lossy(&output.stderr),
))
}
async fn config_unset_all_with_scope(
&self,
key: &str,
scope: &ConfigScope,
) -> Result<(), BabyError> {
let (dir, env) = build_env_for_scope(self, scope).await?;
let mut cmd = helper::run_unset_all(&dir, env, scope, key)?;
let output = cmd.run_soft().await.map_err(BabyError::from)?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.code() == Some(5) {
return Ok(());
}
if output.status.code() == Some(1) && stderr.trim().is_empty() {
return Ok(());
}
Err(helper::classify_set_failure(key, &stderr))
}
async fn config_get_all_with_scope(
&self,
key: &str,
scope: &ConfigScope,
) -> Result<Vec<String>, BabyError> {
let (dir, env) = build_env_for_scope(self, scope).await?;
let mut cmd = helper::run_get_all(&dir, env, scope, key)?;
let output = cmd.run_soft().await.map_err(BabyError::from)?;
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.to_string())
.collect());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.code() == Some(1) && stderr.trim().is_empty() {
return Ok(Vec::new());
}
Err(helper::classify_get_failure(key, &stderr))
}
async fn config_get_regexp_with_scope(
&self,
pattern: &str,
scope: &ConfigScope,
) -> Result<Vec<ConfigEntry>, BabyError> {
let (dir, env) = build_env_for_scope(self, scope).await?;
let mut cmd = helper::run_get_regexp(&dir, env, scope, pattern)?;
let output = cmd.run_soft().await.map_err(BabyError::from)?;
if output.status.success() {
return Ok(helper::parse_kv_lines(&output.stdout));
}
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.code() == Some(1) && stderr.trim().is_empty() {
return Ok(Vec::new());
}
Err(helper::classify_get_failure(pattern, &stderr))
}
}

147
src/conflict/helper.rs Normal file
View File

@ -0,0 +1,147 @@
use std::path::Path;
use gix::ObjectId;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::command::error::CmdError;
use crate::conflict::types::{ConflictFileHeader, ListConflictOptions, MergeTreeResult};
use crate::error::BabyError;
use crate::share;
pub const CALLER: &str = "gitbaby::conflict";
pub fn build_merge_tree_cmd(
dir: &Path,
env: Env,
ours: &str,
theirs: &str,
merge_base: Option<&str>,
allow_tree_conflicts: bool,
) -> Cmd {
let mut args: Vec<String> = vec![
"merge-tree".into(),
"--write-tree".into(),
"-z".into(),
"--name-only".into(),
"--no-messages".into(),
];
if allow_tree_conflicts {
args.push("--allow-unrelated-histories".into());
}
if let Some(mb) = merge_base {
args.push(format!("--merge-base={}", mb));
args.push(ours.into());
args.push(theirs.into());
} else {
args.push(ours.into());
args.push(theirs.into());
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_list_conflict_files_cmd(
dir: &Path,
env: Env,
ours: &str,
theirs: &str,
opts: &ListConflictOptions,
) -> Cmd {
let mut args: Vec<String> = vec!["merge-tree".into(), "-z".into()];
if opts.allow_tree_conflicts {
args.push("--allow-unrelated-histories".into());
}
args.push(ours.into());
args.push(theirs.into());
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn classify_stderr(stderr: &str, ours: &str, theirs: &str) -> BabyError {
if stderr.contains("fatal: bad revision") {
return BabyError::RevisionNotFound(format!("{}..{}", ours, theirs));
}
BabyError::Custom(format!("git merge-tree failed: {}", stderr.trim()))
}
pub fn classify_cmd_error(err: CmdError, ours: &str, theirs: &str) -> BabyError {
match err {
CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, ours, theirs),
CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
other => BabyError::from(other),
}
}
pub fn parse_merge_tree_output(
data: &[u8],
ours_oid: ObjectId,
) -> Result<MergeTreeResult, BabyError> {
let s = String::from_utf8_lossy(data);
let mut iter = s.split('\0');
let first = iter.next().unwrap_or("");
let tree_oid = ObjectId::from_hex(first.as_bytes()).map_err(|source| {
BabyError::Custom(format!("invalid merge-tree oid `{}`: {}", first, source))
})?;
let conflict_files: Vec<std::path::PathBuf> = iter
.filter(|p| !p.is_empty())
.map(std::path::PathBuf::from)
.collect();
let has_conflicts = !conflict_files.is_empty();
let _ = ours_oid;
Ok(MergeTreeResult {
tree_oid,
has_conflicts,
conflict_files,
})
}
pub fn parse_list_conflict_files(
data: &[u8],
ours_oid: ObjectId,
) -> Result<Vec<ConflictFileHeader>, BabyError> {
let s = String::from_utf8_lossy(data);
let mut headers = Vec::new();
let mut current_mode: Option<u32> = None;
let mut their_path: Option<std::path::PathBuf> = None;
let mut our_path: Option<std::path::PathBuf> = None;
let mut ancestor_path: Option<std::path::PathBuf> = None;
let mut mode_count: u8 = 0;
for part in s.split('\0') {
if part.is_empty() {
continue;
}
if mode_count < 3
&& let Ok(m) = share::parse_git_mode(part)
{
current_mode = Some(m);
mode_count += 1;
continue;
}
match mode_count {
1 => {
our_path = Some(std::path::PathBuf::from(part));
mode_count = 2;
current_mode = None;
}
2 => {
their_path = Some(std::path::PathBuf::from(part));
mode_count = 3;
current_mode = None;
}
_ => {
if ancestor_path.is_none() {
ancestor_path = Some(std::path::PathBuf::from(part));
headers.push(ConflictFileHeader {
our_commit_oid: ours_oid,
their_path: their_path.take(),
our_path: our_path.take(),
ancestor_path: ancestor_path.take(),
our_mode: current_mode.unwrap_or(0),
});
mode_count = 0;
current_mode = None;
}
}
}
}
Ok(headers)
}

8
src/conflict/mod.rs Normal file
View File

@ -0,0 +1,8 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::{
ConflictFile, ConflictFileHeader, ConflictResolution, ListConflictOptions, MergeStage,
MergeTreeResult, ResolveConflictsInput,
};

57
src/conflict/types.rs Normal file
View File

@ -0,0 +1,57 @@
use std::path::PathBuf;
use gix::ObjectId;
use crate::commit::types::Signature;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeStage {
Ancestor,
Ours,
Theirs,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictFileHeader {
pub our_commit_oid: ObjectId,
pub their_path: Option<PathBuf>,
pub our_path: Option<PathBuf>,
pub ancestor_path: Option<PathBuf>,
pub our_mode: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictFile {
pub header: ConflictFileHeader,
pub content: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub struct ListConflictOptions {
pub allow_tree_conflicts: bool,
pub skip_content: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MergeTreeResult {
pub tree_oid: ObjectId,
pub has_conflicts: bool,
pub conflict_files: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct ResolveConflictsInput {
pub our_commit_oid: ObjectId,
pub their_commit_oid: ObjectId,
pub source_branch: String,
pub target_branch: String,
pub commit_message: String,
pub author: Signature,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictResolution {
pub old_path: Option<PathBuf>,
pub new_path: PathBuf,
pub content: String,
}

132
src/conflict/usecase.rs Normal file
View File

@ -0,0 +1,132 @@
use std::path::Path;
use gix::ObjectId;
use crate::GitBaby;
use crate::command::error::CmdError;
use crate::conflict::helper;
use crate::conflict::types::{
ConflictFile, ConflictResolution, ListConflictOptions, MergeTreeResult, ResolveConflictsInput,
};
use crate::error::BabyError;
impl GitBaby {
pub async fn list_conflict_files(
&self,
our: &str,
their: &str,
opts: &ListConflictOptions,
) -> Result<Vec<ConflictFile>, BabyError> {
let ours_oid = ObjectId::from_hex(our.as_bytes()).map_err(|source| {
BabyError::Custom(format!("invalid our oid `{}`: {}", our, source))
})?;
let mut cmd = self.spawn_conflict_cmd(our, their, opts).await?;
let output = match cmd.run().await {
Ok(o) => o,
Err(CmdError::NonZeroExit { stderr, .. }) => {
return Err(helper::classify_stderr(&stderr, our, their));
}
Err(e) => return Err(helper::classify_cmd_error(e, our, their)),
};
let headers = helper::parse_list_conflict_files(&output.stdout, ours_oid)?;
let files: Vec<ConflictFile> = headers
.into_iter()
.map(|header| ConflictFile {
header,
content: None,
})
.collect();
Ok(files)
}
pub async fn merge_tree(
&self,
ours: &str,
theirs: &str,
merge_base: Option<&str>,
allow_tree_conflicts: bool,
) -> Result<MergeTreeResult, BabyError> {
let ours_oid = ObjectId::from_hex(ours.as_bytes()).map_err(|source| {
BabyError::Custom(format!("invalid ours oid `{}`: {}", ours, source))
})?;
let mut cmd = self
.spawn_merge_tree_cmd(ours, theirs, merge_base, allow_tree_conflicts)
.await?;
let output = match cmd.run().await {
Ok(o) => o,
Err(CmdError::NonZeroExit { ref stderr, .. }) => {
return Err(helper::classify_stderr(stderr, ours, theirs));
}
Err(e) => return Err(helper::classify_cmd_error(e, ours, theirs)),
};
let stdout = output.stdout.clone();
let _ = output;
helper::parse_merge_tree_output(&stdout, ours_oid)
}
pub async fn resolve_conflicts<I>(
&self,
_input: &ResolveConflictsInput,
resolutions: I,
) -> Result<ObjectId, BabyError>
where
I: IntoIterator<Item = ConflictResolution>,
{
let blobs: Vec<ConflictResolution> = resolutions.into_iter().collect();
let _ = blobs.len();
Err(BabyError::Unimplemented("resolve_conflicts"))
}
async fn spawn_conflict_cmd(
&self,
ours: &str,
their: &str,
opts: &ListConflictOptions,
) -> Result<crate::command::cmd::Cmd, BabyError> {
let dir = self
.facade
.git_repo_dir()
.await
.map_err(crate::share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(crate::share::facade_error)?;
let env = crate::share::build_env_safe(&alternates);
Ok(helper::build_list_conflict_files_cmd(
&dir, env, ours, their, opts,
))
}
async fn spawn_merge_tree_cmd(
&self,
ours: &str,
theirs: &str,
merge_base: Option<&str>,
allow_tree_conflicts: bool,
) -> Result<crate::command::cmd::Cmd, BabyError> {
let dir = self
.facade
.git_repo_dir()
.await
.map_err(crate::share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(crate::share::facade_error)?;
let env = crate::share::build_env_safe(&alternates);
Ok(helper::build_merge_tree_cmd(
&dir,
env,
ours,
theirs,
merge_base,
allow_tree_conflicts,
))
}
#[allow(dead_code)]
fn _path_marker(_p: &Path) {}
}

471
src/diff/helper.rs Normal file
View File

@ -0,0 +1,471 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use gix::ObjectId;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::command::error::CmdError;
use crate::diff::types::{
ChangedPath, ChangedPathDiff, ChangedPathRequest, DiffOptions, DiffRequest, DiffStatus,
NumStat, RangeDiffSpec, ShortStat, WhitespaceMode,
};
use crate::error::BabyError;
use crate::share;
pub const CALLER: &str = "gitbaby::diff";
pub fn validate_paths(paths: &[PathBuf]) -> Result<(), BabyError> {
for p in paths {
share::validate_local_no_escape(p)?;
}
Ok(())
}
pub fn build_diff_args(req: &DiffRequest, opts: &DiffOptions, hex_len: Option<u8>) -> Vec<String> {
let mut args: Vec<String> = vec!["diff".into(), "--full-index".into()];
let abbrev = hex_len
.map(|n| format!("--abbrev={}", n))
.unwrap_or_else(|| "--abbrev=40".into());
args.push(abbrev);
match opts.ignore_whitespace {
Some(WhitespaceMode::IgnoreSpaceChange) => args.push("--ignore-space-change".into()),
Some(WhitespaceMode::IgnoreAllSpace) => args.push("--ignore-all-space".into()),
Some(WhitespaceMode::None) | None => {}
}
if let Some(threshold) = opts.detect_renames {
args.push(format!("--find-renames={}%", threshold));
}
if opts.word_diff {
args.push("--word-diff=porcelain".into());
}
args.push(req.left.to_hex().to_string());
args.push(req.right.to_hex().to_string());
if let Some(paths) = &opts.paths
&& !paths.is_empty()
{
args.push("--".into());
for p in paths {
args.push(share::to_string_lossy_owned(p));
}
}
args
}
pub fn build_raw_diff_cmd(
dir: &Path,
env: Env,
req: &DiffRequest,
opts: &DiffOptions,
hex_len: Option<u8>,
) -> Cmd {
let args = build_diff_args(req, opts, hex_len);
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_patch_diff_cmd(
dir: &Path,
env: Env,
req: &DiffRequest,
_opts: &DiffOptions,
_hex_len: Option<u8>,
) -> Cmd {
let args: Vec<String> = vec![
"format-patch".into(),
"--stdout".into(),
format!("{}..{}", req.left.to_hex(), req.right.to_hex()),
"--no-signature".into(),
];
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_commit_diff_cmd(
dir: &Path,
env: Env,
req: &DiffRequest,
opts: &DiffOptions,
hex_len: Option<u8>,
) -> Cmd {
let mut args: Vec<String> = vec![
"diff".into(),
"--patch".into(),
"--raw".into(),
"--find-renames=30%".into(),
];
let abbrev = hex_len
.map(|n| format!("--abbrev={}", n))
.unwrap_or_else(|| "--abbrev=40".into());
args.push(abbrev);
args.push("--full-index".into());
match opts.ignore_whitespace {
Some(WhitespaceMode::IgnoreSpaceChange) => args.push("--ignore-space-change".into()),
Some(WhitespaceMode::IgnoreAllSpace) => args.push("--ignore-all-space".into()),
Some(WhitespaceMode::None) | None => {}
}
if opts.word_diff {
args.push("--word-diff=porcelain".into());
}
args.push(req.left.to_hex().to_string());
args.push(req.right.to_hex().to_string());
if let Some(paths) = &opts.paths
&& !paths.is_empty()
{
args.push("--".into());
for p in paths {
args.push(share::to_string_lossy_owned(p));
}
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_commit_delta_cmd(
dir: &Path,
env: Env,
req: &DiffRequest,
opts: &DiffOptions,
hex_len: Option<u8>,
) -> Cmd {
let mut args: Vec<String> = vec!["diff".into(), "--raw".into(), "--find-renames=30%".into()];
let abbrev = hex_len
.map(|n| format!("--abbrev={}", n))
.unwrap_or_else(|| "--abbrev=40".into());
args.push(abbrev);
args.push("--full-index".into());
args.push(req.left.to_hex().to_string());
args.push(req.right.to_hex().to_string());
if let Some(paths) = &opts.paths
&& !paths.is_empty()
{
args.push("--".into());
for p in paths {
args.push(share::to_string_lossy_owned(p));
}
}
let _ = opts;
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_numstat_cmd(dir: &Path, env: Env, req: &DiffRequest, opts: &DiffOptions) -> Cmd {
let mut args: Vec<String> = vec!["diff".into(), "--numstat".into(), "-z".into()];
args.push(req.left.to_hex().to_string());
args.push(req.right.to_hex().to_string());
if let Some(paths) = &opts.paths
&& !paths.is_empty()
{
args.push("--".into());
for p in paths {
args.push(share::to_string_lossy_owned(p));
}
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_shortstat_cmd(dir: &Path, env: Env, req: &DiffRequest, opts: &DiffOptions) -> Cmd {
let mut args: Vec<String> = vec!["diff".into(), "--shortstat".into()];
args.push(req.left.to_hex().to_string());
args.push(req.right.to_hex().to_string());
if let Some(paths) = &opts.paths
&& !paths.is_empty()
{
args.push("--".into());
for p in paths {
args.push(share::to_string_lossy_owned(p));
}
}
let _ = opts;
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_range_diff_cmd(
dir: &Path,
env: Env,
spec: &RangeDiffSpec,
_opts: &DiffOptions,
hex_len: Option<u8>,
) -> Cmd {
let mut args: Vec<String> = vec!["range-diff".into(), "--no-color".into()];
let abbrev = hex_len
.map(|n| format!("--abbrev={}", n))
.unwrap_or_else(|| "--abbrev=40".into());
args.push(abbrev);
match spec {
RangeDiffSpec::Range(r1, r2) => {
args.push(r1.clone());
args.push(r2.clone());
}
RangeDiffSpec::RevisionRange(r1, r2) => {
args.push(format!("{}...{}", r1, r2));
}
RangeDiffSpec::BaseWithRevisions(base, r1, r2) => {
args.push(base.clone());
args.push(r1.clone());
args.push(r2.clone());
}
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_find_changed_paths_cmd(
dir: &Path,
env: Env,
_requests: &[ChangedPathRequest],
filter_status: Option<char>,
detect_renames: Option<u8>,
merge_parents: bool,
) -> Cmd {
let mut args: Vec<String> = vec![
"diff-tree".into(),
"-z".into(),
"--stdin".into(),
"-r".into(),
"--root".into(),
"--no-commit-id".into(),
];
if let Some(ch) = filter_status {
args.push(format!("--diff-filter={}", ch));
} else {
args.push("--diff-filter=AMDTCR".into());
}
match detect_renames {
Some(threshold) => args.push(format!("--find-renames={}%", threshold)),
None => args.push("--no-renames".into()),
}
if merge_parents {
args.push("-m".into());
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_diff_tree_stdin(requests: &[ChangedPathRequest]) -> Vec<u8> {
let mut buf = Vec::new();
for r in requests {
if let ChangedPathRequest::Commit(oid) = r {
buf.extend_from_slice(oid.to_string().as_bytes());
buf.push(b'\n');
}
}
buf
}
pub fn classify_stderr(stderr: &str, left: &str, right: &str) -> BabyError {
if stderr.contains("fatal: bad revision") || stderr.contains("bad revision") {
return BabyError::RevisionNotFound(format!("{}..{}", left, right));
}
BabyError::Custom(format!("git diff failed: {}", stderr.trim()))
}
pub fn classify_cmd_error(err: CmdError, left: &str, right: &str) -> BabyError {
match err {
CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, left, right),
CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
other => BabyError::from(other),
}
}
pub fn parse_raw_diff_line(line: &str, abbrev: usize) -> Result<ChangedPath, BabyError> {
let mut fields = line.splitn(7, ' ');
let old_mode_raw = fields
.next()
.ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?;
let new_mode_raw = fields
.next()
.ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?;
let old_oid_raw = fields
.next()
.ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?;
let new_oid_raw = fields
.next()
.ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?;
let status_raw = fields
.next()
.ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?;
let scores_or_path = fields
.next()
.ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?;
let path = fields
.next()
.ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?;
let status_ch = status_raw
.chars()
.next()
.ok_or_else(|| BabyError::Custom(format!("raw diff status missing: `{}`", line)))?;
let status = DiffStatus::from_char(status_ch)
.ok_or_else(|| BabyError::Custom(format!("unknown diff status `{}`", status_ch)))?;
let old_mode = share::parse_git_mode(old_mode_raw)?;
let new_mode = share::parse_git_mode(new_mode_raw)?;
let old_oid = if old_oid_raw == "0000000000000000000000000000000000000000"
|| old_oid_raw.len() < abbrev
{
None
} else {
Some(parse_oid_prefix(old_oid_raw)?)
};
let new_oid = if new_oid_raw == "0000000000000000000000000000000000000000"
|| new_oid_raw.len() < abbrev
{
None
} else {
Some(parse_oid_prefix(new_oid_raw)?)
};
let (old_path, new_path) = match status {
DiffStatus::Renamed | DiffStatus::Copied => {
(Some(PathBuf::from(scores_or_path)), PathBuf::from(path))
}
_ => (None, PathBuf::from(path)),
};
Ok(ChangedPath {
old_path,
new_path,
old_mode,
new_mode,
old_oid,
new_oid,
status,
})
}
pub fn parse_oid_prefix(hex: &str) -> Result<ObjectId, BabyError> {
ObjectId::from_hex(hex.as_bytes())
.map_err(|source| BabyError::Custom(format!("invalid oid `{}`: {}", hex, source)))
}
pub fn parse_numstat_output(data: &[u8]) -> Result<Vec<NumStat>, BabyError> {
let mut stats = Vec::new();
let s = String::from_utf8_lossy(data);
for chunk in s.split('\0') {
if chunk.is_empty() {
continue;
}
let mut parts = chunk.splitn(3, '\t');
let adds_raw = parts.next().unwrap_or("");
let dels_raw = parts.next().unwrap_or("");
let last = parts.next().unwrap_or("");
let mut iter = last.splitn(2, '\t');
let first = iter.next().unwrap_or("");
let second = iter.next();
let mut stat = NumStat {
additions: parse_numstat_number(adds_raw)?,
deletions: parse_numstat_number(dels_raw)?,
path: PathBuf::new(),
old_path: None,
};
match second {
Some(p) => {
stat.old_path = Some(PathBuf::from(first));
stat.path = PathBuf::from(p);
}
None => {
stat.path = PathBuf::from(first);
}
}
stats.push(stat);
}
Ok(stats)
}
fn parse_numstat_number(s: &str) -> Result<u64, BabyError> {
if s == "-" {
return Ok(0);
}
s.parse::<u64>()
.map_err(|e| BabyError::Custom(format!("invalid numstat number `{}`: {}", s, e)))
}
pub fn parse_shortstat_output(data: &[u8]) -> Result<ShortStat, BabyError> {
let s = String::from_utf8_lossy(data);
let mut stat = ShortStat::default();
let mut rest = s.trim();
if let Some(idx) = rest.find("files changed") {
stat.files_changed = parse_u64_before(rest, idx)?;
rest = &rest[idx + "files changed".len()..];
}
if let Some(idx) = rest.find("insertions") {
stat.additions = parse_u64_before(rest, idx)?;
rest = &rest[idx + "insertions".len()..];
}
if let Some(idx) = rest.find("deletions") {
stat.deletions = parse_u64_before(rest, idx)?;
}
Ok(stat)
}
fn parse_u64_before(s: &str, before: usize) -> Result<u64, BabyError> {
let prefix = &s[..before];
let trimmed = prefix.trim().trim_end_matches(',').trim();
trimmed
.parse::<u64>()
.map_err(|e| BabyError::Custom(format!("invalid number `{}`: {}", trimmed, e)))
}
pub fn parse_commit_diff_output(
data: &[u8],
abbrev: usize,
) -> Result<Vec<ChangedPathDiff>, BabyError> {
let s = String::from_utf8_lossy(data);
let mut out: Vec<ChangedPathDiff> = Vec::new();
let mut current: Option<ChangedPathDiff> = None;
let mut patch_bytes: Vec<u8> = Vec::new();
let mut in_diff = false;
for line in s.lines() {
if line.starts_with("diff --git ") {
if let Some(c) = current.take() {
let mut finalized = c;
if !patch_bytes.is_empty() {
finalized.patch = Some(std::mem::take(&mut patch_bytes));
}
out.push(finalized);
}
patch_bytes.clear();
in_diff = true;
current = None;
} else if line.starts_with(':') {
if let Ok(cp) = parse_raw_diff_line(line, abbrev) {
let _ = cp;
current = Some(ChangedPathDiff {
path: cp,
lines_added: 0,
lines_removed: 0,
binary: false,
patch: None,
});
}
} else if in_diff {
if line.starts_with("Binary files") {
if let Some(c) = current.as_mut() {
c.binary = true;
}
} else if line.starts_with('+')
&& !line.starts_with("+++")
&& let Some(c) = current.as_mut()
{
c.lines_added = c.lines_added.saturating_add(1);
} else if line.starts_with('-')
&& !line.starts_with("---")
&& let Some(c) = current.as_mut()
{
c.lines_removed = c.lines_removed.saturating_add(1);
}
patch_bytes.extend_from_slice(line.as_bytes());
patch_bytes.push(b'\n');
}
}
if let Some(c) = current.take() {
let mut finalized = c;
if !patch_bytes.is_empty() {
finalized.patch = Some(patch_bytes);
}
out.push(finalized);
}
let _ = Arc::new(());
Ok(out)
}
pub fn parse_diff_tree_status_line(line: &str, abbrev: usize) -> Result<ChangedPath, BabyError> {
parse_raw_diff_line(line, abbrev)
}

8
src/diff/mod.rs Normal file
View File

@ -0,0 +1,8 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::{
ChangedPath, ChangedPathDiff, ChangedPathRequest, DiffOptions, DiffRequest, DiffStatus,
NumStat, RangeDiffSpec, ShortStat, WhitespaceMode,
};

96
src/diff/types.rs Normal file
View File

@ -0,0 +1,96 @@
use std::path::PathBuf;
use gix::ObjectId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffRequest {
pub left: ObjectId,
pub right: ObjectId,
}
#[derive(Debug, Default, Clone)]
pub struct DiffOptions {
pub paths: Option<Vec<PathBuf>>,
pub ignore_whitespace: Option<WhitespaceMode>,
pub detect_renames: Option<u8>,
pub word_diff: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WhitespaceMode {
None,
IgnoreSpaceChange,
IgnoreAllSpace,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffStatus {
Added,
Deleted,
Modified,
TypeChanged,
Renamed,
Copied,
}
impl DiffStatus {
pub fn from_char(ch: char) -> Option<Self> {
match ch {
'A' => Some(Self::Added),
'D' => Some(Self::Deleted),
'M' => Some(Self::Modified),
'T' => Some(Self::TypeChanged),
'R' => Some(Self::Renamed),
'C' => Some(Self::Copied),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangedPath {
pub old_path: Option<PathBuf>,
pub new_path: PathBuf,
pub old_mode: u32,
pub new_mode: u32,
pub old_oid: Option<ObjectId>,
pub new_oid: Option<ObjectId>,
pub status: DiffStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangedPathDiff {
pub path: ChangedPath,
pub lines_added: u64,
pub lines_removed: u64,
pub binary: bool,
pub patch: Option<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NumStat {
pub additions: u64,
pub deletions: u64,
pub path: PathBuf,
pub old_path: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ShortStat {
pub files_changed: u64,
pub additions: u64,
pub deletions: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChangedPathRequest {
Commit(ObjectId),
TreePair(ObjectId, ObjectId),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RangeDiffSpec {
Range(String, String),
RevisionRange(String, String),
BaseWithRevisions(String, String, String),
}

407
src/diff/usecase.rs Normal file
View File

@ -0,0 +1,407 @@
use std::path::Path;
use gix::ObjectId;
use tokio::io::{AsyncBufReadExt, AsyncReadExt};
use crate::GitBaby;
use crate::command::error::CmdError;
use crate::diff::helper;
use crate::diff::types::{
ChangedPath, ChangedPathDiff, ChangedPathRequest, DiffOptions, DiffRequest, NumStat,
RangeDiffSpec, ShortStat,
};
use crate::error::BabyError;
impl GitBaby {
pub async fn raw_diff(
&self,
req: &DiffRequest,
hex_len: Option<u8>,
) -> Result<String, BabyError> {
let opts = DiffOptions::default();
let mut cmd = self
.spawn_diff_cmd(req, hex_len, &opts, helper::build_raw_diff_cmd)
.await?;
run_capture(&mut cmd, &req.left.to_string(), &req.right.to_string()).await
}
pub async fn raw_diff_stream<F>(
&self,
req: &DiffRequest,
hex_len: Option<u8>,
#[allow(unused_mut)] mut callback: F,
) -> Result<(), BabyError>
where
F: FnMut(&[u8]) + Send,
{
let opts = DiffOptions::default();
let mut cmd = self
.spawn_diff_cmd(req, hex_len, &opts, helper::build_raw_diff_cmd)
.await?;
run_stream(
&mut cmd,
&req.left.to_string(),
&req.right.to_string(),
callback,
)
.await
}
pub async fn patch_diff(
&self,
req: &DiffRequest,
hex_len: Option<u8>,
) -> Result<String, BabyError> {
let opts = DiffOptions::default();
let mut cmd = self
.spawn_diff_cmd(req, hex_len, &opts, helper::build_patch_diff_cmd)
.await?;
run_capture(&mut cmd, &req.left.to_string(), &req.right.to_string()).await
}
pub async fn patch_diff_stream<F>(
&self,
req: &DiffRequest,
hex_len: Option<u8>,
#[allow(unused_mut)] mut callback: F,
) -> Result<(), BabyError>
where
F: FnMut(&[u8]) + Send,
{
let opts = DiffOptions::default();
let mut cmd = self
.spawn_diff_cmd(req, hex_len, &opts, helper::build_patch_diff_cmd)
.await?;
run_stream(
&mut cmd,
&req.left.to_string(),
&req.right.to_string(),
callback,
)
.await
}
pub async fn commit_delta(
&self,
req: &DiffRequest,
opts: &DiffOptions,
hex_len: Option<u8>,
) -> Result<Vec<ChangedPath>, BabyError> {
if let Some(paths) = &opts.paths {
helper::validate_paths(paths)?;
}
let mut cmd = self
.spawn_diff_cmd(req, hex_len, opts, helper::build_commit_delta_cmd)
.await?;
let output = run_output(&mut cmd, &req.left.to_string(), &req.right.to_string()).await?;
let abbrev = hex_len.map(|n| n as usize).unwrap_or(40);
let mut paths: Vec<ChangedPath> = Vec::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
if line.is_empty() {
continue;
}
paths.push(helper::parse_raw_diff_line(line, abbrev)?);
}
Ok(paths)
}
pub async fn commit_diff(
&self,
req: &DiffRequest,
opts: &DiffOptions,
hex_len: Option<u8>,
) -> Result<Vec<ChangedPathDiff>, BabyError> {
if let Some(paths) = &opts.paths {
helper::validate_paths(paths)?;
}
let mut cmd = self
.spawn_diff_cmd(req, hex_len, opts, helper::build_commit_diff_cmd)
.await?;
let output = run_output(&mut cmd, &req.left.to_string(), &req.right.to_string()).await?;
let abbrev = hex_len.map(|n| n as usize).unwrap_or(40);
helper::parse_commit_diff_output(&output.stdout, abbrev)
}
pub async fn find_changed_paths(
&self,
requests: Vec<ChangedPathRequest>,
filter_status: Option<char>,
detect_renames: Option<u8>,
merge_parents: bool,
) -> Result<Vec<ChangedPath>, BabyError> {
let mut cmd = self
.spawn_diff_state_cmd(&requests, filter_status, detect_renames, merge_parents)
.await?;
let payload = helper::build_diff_tree_stdin(&requests);
let output = match cmd.feed(&payload).await {
Ok(()) => match cmd.run().await {
Ok(o) => o,
Err(CmdError::NonZeroExit { stderr, .. }) => {
return Err(helper::classify_stderr(
&stderr,
&requests.first().map_or_else(String::new, |r| match r {
ChangedPathRequest::Commit(o) => o.to_hex().to_string(),
ChangedPathRequest::TreePair(l, _) => l.to_hex().to_string(),
}),
"",
));
}
Err(e) => return Err(BabyError::from(e)),
},
Err(e) => return Err(BabyError::from(e)),
};
let abbrev = 40;
let mut paths: Vec<ChangedPath> = Vec::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
if line.is_empty() {
continue;
}
paths.push(helper::parse_diff_tree_status_line(line, abbrev)?);
}
Ok(paths)
}
pub async fn diff_stats(
&self,
req: &DiffRequest,
opts: &DiffOptions,
) -> Result<Vec<NumStat>, BabyError> {
if let Some(paths) = &opts.paths {
helper::validate_paths(paths)?;
}
let mut cmd = self
.spawn_diff_cmd_no_hex(req, opts, helper::build_numstat_cmd)
.await?;
let output = run_output(&mut cmd, &req.left.to_string(), &req.right.to_string()).await?;
helper::parse_numstat_output(&output.stdout)
}
pub async fn shortstat(
&self,
req: &DiffRequest,
opts: &DiffOptions,
) -> Result<ShortStat, BabyError> {
if let Some(paths) = &opts.paths {
helper::validate_paths(paths)?;
}
let mut cmd = self
.spawn_diff_cmd_no_hex(req, opts, helper::build_shortstat_cmd)
.await?;
let output = run_output(&mut cmd, &req.left.to_string(), &req.right.to_string()).await?;
helper::parse_shortstat_output(&output.stdout)
}
pub async fn range_diff(
&self,
spec: &RangeDiffSpec,
opts: &DiffOptions,
hex_len: Option<u8>,
) -> Result<String, BabyError> {
let mut cmd = self.spawn_range_diff_cmd(spec, opts, hex_len).await?;
let (left, right) = match spec {
RangeDiffSpec::Range(r1, r2) | RangeDiffSpec::RevisionRange(r1, r2) => {
(r1.clone(), r2.clone())
}
RangeDiffSpec::BaseWithRevisions(_, r1, r2) => (r1.clone(), r2.clone()),
};
run_capture(&mut cmd, &left, &right).await
}
async fn spawn_diff_cmd<F>(
&self,
req: &DiffRequest,
hex_len: Option<u8>,
opts: &DiffOptions,
builder: F,
) -> Result<crate::command::cmd::Cmd, BabyError>
where
F: FnOnce(
&Path,
crate::command::env::Env,
&DiffRequest,
&DiffOptions,
Option<u8>,
) -> crate::command::cmd::Cmd,
{
let dir = self
.facade
.git_repo_dir()
.await
.map_err(crate::share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(crate::share::facade_error)?;
let env = crate::share::build_env_safe(&alternates);
Ok(builder(&dir, env, req, opts, hex_len))
}
async fn spawn_diff_cmd_no_hex<F>(
&self,
req: &DiffRequest,
opts: &DiffOptions,
builder: F,
) -> Result<crate::command::cmd::Cmd, BabyError>
where
F: FnOnce(
&Path,
crate::command::env::Env,
&DiffRequest,
&DiffOptions,
) -> crate::command::cmd::Cmd,
{
let dir = self
.facade
.git_repo_dir()
.await
.map_err(crate::share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(crate::share::facade_error)?;
let env = crate::share::build_env_safe(&alternates);
Ok(builder(&dir, env, req, opts))
}
async fn spawn_diff_state_cmd(
&self,
requests: &[ChangedPathRequest],
filter_status: Option<char>,
detect_renames: Option<u8>,
merge_parents: bool,
) -> Result<crate::command::cmd::Cmd, BabyError> {
let dir = self
.facade
.git_repo_dir()
.await
.map_err(crate::share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(crate::share::facade_error)?;
let env = crate::share::build_env_safe(&alternates);
Ok(helper::build_find_changed_paths_cmd(
&dir,
env,
requests,
filter_status,
detect_renames,
merge_parents,
))
}
async fn spawn_range_diff_cmd(
&self,
spec: &RangeDiffSpec,
opts: &DiffOptions,
hex_len: Option<u8>,
) -> Result<crate::command::cmd::Cmd, BabyError> {
let dir = self
.facade
.git_repo_dir()
.await
.map_err(crate::share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(crate::share::facade_error)?;
let env = crate::share::build_env_safe(&alternates);
Ok(helper::build_range_diff_cmd(&dir, env, spec, opts, hex_len))
}
}
async fn run_capture(
cmd: &mut crate::command::cmd::Cmd,
left: &str,
right: &str,
) -> Result<String, BabyError> {
let output = match cmd.run().await {
Ok(o) => o,
Err(CmdError::NonZeroExit { ref stderr, .. }) => {
return Err(helper::classify_stderr(stderr, left, right));
}
Err(e) => return Err(helper::classify_cmd_error(e, left, right)),
};
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
left,
right,
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
async fn run_output(
cmd: &mut crate::command::cmd::Cmd,
left: &str,
right: &str,
) -> Result<crate::command::cmd::CmdOutput, BabyError> {
let output = match cmd.run().await {
Ok(o) => o,
Err(CmdError::NonZeroExit { ref stderr, .. }) => {
return Err(helper::classify_stderr(stderr, left, right));
}
Err(e) => return Err(helper::classify_cmd_error(e, left, right)),
};
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
left,
right,
));
}
Ok(output)
}
async fn run_stream<F>(
cmd: &mut crate::command::cmd::Cmd,
left: &str,
right: &str,
mut callback: F,
) -> Result<(), BabyError>
where
F: FnMut(&[u8]) + Send,
{
if let Err(e) = cmd.spawn().await {
return Err(helper::classify_cmd_error(e, left, right));
}
let mut stdout = std::mem::replace(&mut cmd.stdout, Box::new(tokio::io::empty()));
let mut reader = tokio::io::BufReader::new(&mut stdout);
let mut buf = Vec::with_capacity(8192);
loop {
buf.clear();
let n = match reader.read_until(b'\n', &mut buf).await {
Ok(n) => n,
Err(source) => {
return Err(BabyError::Custom(format!("stream read failed: {}", source)));
}
};
if n == 0 {
break;
}
callback(&buf);
}
let status = match cmd.wait().await {
Ok(s) => s,
Err(e) => return Err(helper::classify_cmd_error(e, left, right)),
};
if !status.success() {
let mut stderr = std::mem::replace(&mut cmd.stderr, Box::new(tokio::io::empty()));
let mut sbuf = Vec::new();
let _ = stderr.read_to_end(&mut sbuf).await;
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&sbuf),
left,
right,
));
}
Ok(())
}
#[allow(dead_code)]
fn _oid_marker(_o: ObjectId) {}

277
src/error.rs Normal file
View File

@ -0,0 +1,277 @@
use std::fmt;
use std::path::PathBuf;
use crate::command::error::CmdError;
#[derive(Debug)]
pub enum BabyError {
Custom(String),
Command(CmdError),
Io {
path: PathBuf,
source: std::io::Error,
},
Spawn {
prog: String,
source: std::io::Error,
stderr: String,
},
InvalidRange,
PathEscapesRepository,
RevisionEmpty,
PathNotFound {
revision: String,
path: PathBuf,
},
OutOfRange {
actual_lines: u64,
revision: String,
path: PathBuf,
},
IgnoreRevsNotBlob {
revision: String,
},
Parse {
line_number: u64,
message: String,
},
RevisionNotFound(String),
RevisionInvalid(String),
NotATree {
revision: String,
path: PathBuf,
},
CommitParse {
line_number: u64,
message: String,
},
InvalidTimestamp(String),
BranchNotFound(String),
BranchAlreadyExists(String),
RefNotFound(String),
TagNotFound(String),
TagAlreadyExists(String),
InvalidRefName(String),
InvalidBranchName(String),
InvalidTagName(String),
RefUpdateRejected(String),
AmbiguousRef {
query: String,
candidates: Vec<String>,
},
TagVerifyFailed {
name: String,
reason: String,
},
MergeBaseNotFound(String),
ResolutionError {
reason: String,
},
BlobNotFound {
oid: String,
},
LFSPointerInvalid {
oid: String,
reason: String,
},
ConfigGetFailed {
key: String,
stderr: String,
},
ConfigSetFailed {
key: String,
stderr: String,
},
ArchiveFailed(String),
SubModuleParse {
line: u64,
reason: String,
},
RemoteNotFound(String),
RemoteAlreadyExists(String),
InvalidRemoteUrl(String),
FastImportFailed(String),
NotARepository(String),
GcFailed(String),
InvalidGitArg(String),
Unimplemented(&'static str),
PayloadTooLarge {
cap_bytes: u64,
actual_bytes: u64,
},
}
impl fmt::Display for BabyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Custom(msg) => write!(f, "{}", msg),
Self::Command(err) => write!(f, "{}", err),
Self::Io { path, source } => {
write!(f, "io error at `{}`: {}", path.display(), source)
}
Self::Spawn {
prog,
source,
stderr,
} => write!(
f,
"`{}` failed: {} (stderr: {})",
prog,
source,
stderr.trim()
),
Self::InvalidRange => {
write!(f, "invalid line range; expected 'A,B' with A <= B")
}
Self::PathEscapesRepository => write!(
f,
"blame path escapes repository (must be relative and contain no '..' components)"
),
Self::RevisionEmpty => write!(f, "revision must not be empty"),
Self::PathNotFound { revision, path } => write!(
f,
"path `{}` does not exist at revision `{}`",
path.display(),
revision
),
Self::OutOfRange {
actual_lines,
revision,
path,
} => write!(
f,
"path `{}` at revision `{}` has only {} line(s), below the requested range",
path.display(),
revision,
actual_lines
),
Self::IgnoreRevsNotBlob { revision } => write!(
f,
"ignore-revs file at revision `{}` is not a blob",
revision
),
Self::Parse {
line_number,
message,
} => write!(
f,
"malformed git blame output at line {}: {}",
line_number, message
),
Self::RevisionNotFound(rev) => {
write!(f, "revision `{}` does not exist", rev)
}
Self::RevisionInvalid(rev) => {
write!(f, "revision `{}` is malformed or ambiguous", rev)
}
Self::NotATree { revision, path } => write!(
f,
"`{}` at revision `{}` is not a tree",
path.display(),
revision
),
Self::CommitParse {
line_number,
message,
} => write!(
f,
"malformed commit porcelain at line {}: {}",
line_number, message
),
Self::InvalidTimestamp(input) => {
write!(f, "invalid git timestamp: `{}`", input)
}
Self::BranchNotFound(name) => write!(f, "branch `{}` does not exist", name),
Self::BranchAlreadyExists(name) => write!(f, "branch `{}` already exists", name),
Self::RefNotFound(name) => write!(f, "ref `{}` does not exist", name),
Self::TagNotFound(name) => write!(f, "tag `{}` does not exist", name),
Self::TagAlreadyExists(name) => write!(f, "tag `{}` already exists", name),
Self::InvalidRefName(name) => write!(f, "invalid ref name `{}`", name),
Self::InvalidBranchName(name) => write!(f, "invalid branch name `{}`", name),
Self::InvalidTagName(name) => write!(f, "invalid tag name `{}`", name),
Self::RefUpdateRejected(reason) => {
write!(f, "ref update rejected by git: {}", reason.trim())
}
Self::AmbiguousRef { query, candidates } => write!(
f,
"ref `{}` is ambiguous; candidates: {}",
query,
candidates.join(", ")
),
Self::TagVerifyFailed { name, reason } => {
write!(
f,
"tag `{}` signature verification failed: {}",
name, reason
)
}
Self::MergeBaseNotFound(spec) => {
write!(f, "no merge-base exists for `{}`", spec)
}
Self::ResolutionError { reason } => {
write!(f, "conflict resolution error: {}", reason)
}
Self::BlobNotFound { oid } => write!(f, "blob `{}` does not exist", oid),
Self::LFSPointerInvalid { oid, reason } => {
write!(f, "blob `{}` is not a valid LFS pointer: {}", oid, reason)
}
Self::ConfigGetFailed { key, stderr } => {
write!(f, "git config get for `{}` failed: {}", key, stderr.trim())
}
Self::ConfigSetFailed { key, stderr } => {
write!(f, "git config set for `{}` failed: {}", key, stderr.trim())
}
Self::ArchiveFailed(reason) => write!(f, "git archive failed: {}", reason.trim()),
Self::SubModuleParse { line, reason } => {
write!(f, "malformed .gitmodules at line {}: {}", line, reason)
}
Self::RemoteNotFound(name) => write!(f, "remote `{}` does not exist", name),
Self::RemoteAlreadyExists(name) => {
write!(f, "remote `{}` already exists", name)
}
Self::InvalidRemoteUrl(url) => write!(f, "invalid remote url `{}`", url),
Self::FastImportFailed(reason) => {
write!(f, "git fast-import failed: {}", reason.trim())
}
Self::NotARepository(path) => write!(f, "`{}` is not a git repository", path),
Self::GcFailed(reason) => write!(f, "git gc failed: {}", reason.trim()),
Self::InvalidGitArg(msg) => write!(f, "invalid git argument: {}", msg),
Self::Unimplemented(what) => write!(f, "not yet implemented: {}", what),
Self::PayloadTooLarge {
cap_bytes,
actual_bytes,
} => write!(
f,
"output exceeds cap: {} bytes > {} bytes cap",
actual_bytes, cap_bytes
),
}
}
}
impl std::error::Error for BabyError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Command(err) => Some(err),
Self::Io { source, .. } => Some(source),
Self::Spawn { source, .. } => Some(source),
Self::AmbiguousRef { .. } | Self::TagVerifyFailed { .. } => None,
_ => None,
}
}
}
impl From<CmdError> for BabyError {
fn from(err: CmdError) -> Self {
Self::Command(err)
}
}
impl From<std::io::Error> for BabyError {
fn from(source: std::io::Error) -> Self {
Self::Io {
path: PathBuf::new(),
source,
}
}
}

50
src/lib.rs Normal file
View File

@ -0,0 +1,50 @@
use std::sync::Arc;
use crate::command::pipe::Pipe;
use crate::repo::RepositoryFacade;
pub mod archive;
pub mod blame;
pub mod blob;
pub mod branch;
pub mod cleanup;
pub mod command;
pub mod commit;
pub mod compare;
pub mod config;
pub mod conflict;
pub mod diff;
pub mod error;
pub mod merge;
pub mod refs;
pub mod remote;
pub mod repo;
pub mod setup;
pub mod share;
pub mod submodule;
pub mod tags;
pub mod tree;
#[derive(Clone)]
pub struct GitBaby {
facade: Arc<dyn RepositoryFacade>,
pipe: Pipe,
}
impl GitBaby {
pub fn new(facade: Arc<dyn RepositoryFacade>, pipe: Pipe) -> Self {
Self { facade, pipe }
}
pub fn facade(&self) -> &dyn RepositoryFacade {
&*self.facade
}
pub fn pipe(&self) -> &Pipe {
&self.pipe
}
pub fn pipe_mut(&mut self) -> &mut Pipe {
&mut self.pipe
}
}

32
src/merge/helper.rs Normal file
View File

@ -0,0 +1,32 @@
use std::path::Path;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::command::error::CmdError;
use crate::error::BabyError;
use crate::share;
pub const CALLER: &str = "gitbaby::merge";
pub fn build_merge_base_cmd(dir: &Path, env: Env, one: &str, two: &str) -> Cmd {
let args: Vec<String> = vec!["merge-base".into(), one.into(), two.into()];
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn classify_stderr(stderr: &str, one: &str, two: &str) -> BabyError {
if stderr.trim().is_empty() {
return BabyError::MergeBaseNotFound(format!("{}..{}", one, two));
}
if stderr.contains("fatal: bad revision") {
return BabyError::RevisionNotFound(format!("{}..{}", one, two));
}
BabyError::Custom(format!("git merge-base failed: {}", stderr.trim()))
}
pub fn classify_cmd_error(err: CmdError, one: &str, two: &str) -> BabyError {
match err {
CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, one, two),
CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
other => BabyError::from(other),
}
}

5
src/merge/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use crate::conflict::MergeTreeResult;

6
src/merge/types.rs Normal file
View File

@ -0,0 +1,6 @@
use gix::ObjectId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MergeBaseResult {
pub oid: ObjectId,
}

53
src/merge/usecase.rs Normal file
View File

@ -0,0 +1,53 @@
use gix::ObjectId;
use crate::GitBaby;
use crate::command::error::CmdError;
use crate::error::BabyError;
use crate::merge::helper;
impl GitBaby {
pub async fn merge_base(&self, one: &str, two: &str) -> Result<ObjectId, BabyError> {
let mut cmd = self.spawn_merge_base_cmd(one, two).await?;
let output = match cmd.run().await {
Ok(o) => o,
Err(CmdError::NonZeroExit { ref stderr, .. }) => {
return Err(helper::classify_stderr(stderr, one, two));
}
Err(e) => return Err(helper::classify_cmd_error(e, one, two)),
};
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
one,
two,
));
}
let text = String::from_utf8_lossy(&output.stdout);
let hex = text.trim();
ObjectId::from_hex(hex.as_bytes()).map_err(|source| {
BabyError::Custom(format!(
"merge-base returned invalid oid `{}`: {}",
hex, source
))
})
}
async fn spawn_merge_base_cmd(
&self,
one: &str,
two: &str,
) -> Result<crate::command::cmd::Cmd, BabyError> {
let dir = self
.facade
.git_repo_dir()
.await
.map_err(crate::share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(crate::share::facade_error)?;
let env = crate::share::build_env_safe(&alternates);
Ok(helper::build_merge_base_cmd(&dir, env, one, two))
}
}

269
src/refs/helper.rs Normal file
View File

@ -0,0 +1,269 @@
use std::path::Path;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::command::error::CmdError;
use crate::error::BabyError;
use crate::share::{git_cmd, validate_ref_name};
use super::types::{ListRefsOptions, RefInfo, RefType, RefUpdate};
pub const CALLER: &str = "gitbaby::refs";
pub fn validate_list_refs_options(opts: &ListRefsOptions) -> Result<(), BabyError> {
for p in &opts.patterns {
if !p.starts_with("refs/") {
return Err(BabyError::InvalidRefName(p.clone()));
}
validate_ref_name(p)?;
}
for p in &opts.exclude_patterns {
if !p.starts_with("refs/") {
return Err(BabyError::InvalidRefName(p.clone()));
}
}
Ok(())
}
pub fn validate_ref_update(update: &RefUpdate) -> Result<(), BabyError> {
validate_ref_name(&update.name)?;
if update.new_sha.is_null() && update.old_sha.is_none() {
return Err(BabyError::InvalidGitArg(format!(
"ref `{}` new_sha is the zero object id (delete) but old_sha is missing",
update.name
)));
}
Ok(())
}
pub fn build_list_refs_cmd(dir: &Path, env: Env, opts: &ListRefsOptions) -> Cmd {
let args = crate::share::build_for_each_ref_args(
&opts.patterns,
None,
opts.limit,
opts.start_after.as_deref(),
None,
&opts.exclude_patterns,
opts.peel_tags,
);
git_cmd(CALLER, dir, env, None, args)
}
pub fn build_find_refs_by_oid_cmd(dir: &Path, env: Env, oid: &str) -> Cmd {
let args = crate::share::build_for_each_ref_args(
&["refs".to_string()],
None,
None,
None,
Some(oid),
&[],
false,
);
git_cmd(CALLER, dir, env, None, args)
}
pub fn build_update_ref_cmd(
dir: &Path,
env: Env,
name: &str,
new_sha: &str,
old_sha: Option<&str>,
atomic: bool,
) -> Cmd {
let mut args = Vec::new();
if atomic {
args.push("update-ref".to_string());
args.push("--stdin".to_string());
} else {
args.push("update-ref".to_string());
let mut a = vec![name.to_string(), new_sha.to_string()];
if let Some(o) = old_sha {
a.push(o.to_string());
}
args.extend(a);
}
git_cmd(CALLER, dir, env, None, args)
}
pub fn build_delete_ref_cmd(dir: &Path, env: Env, name: &str, atomic: bool) -> Cmd {
let args = if atomic {
vec!["update-ref".to_string(), "--stdin".to_string()]
} else {
vec![
"update-ref".to_string(),
"--no-deref".to_string(),
"-d".to_string(),
name.to_string(),
]
};
git_cmd(CALLER, dir, env, None, args)
}
pub fn build_show_ref_cmd(dir: &Path, env: Env, name: &str) -> Cmd {
git_cmd(
CALLER,
dir,
env,
None,
vec![
"show-ref".to_string(),
"--verify".to_string(),
"--".to_string(),
name.to_string(),
],
)
}
pub fn build_revspec_cmd(dir: &Path, env: Env, rev: &str) -> Cmd {
git_cmd(
CALLER,
dir,
env,
None,
vec![
"rev-parse".to_string(),
"--verify".to_string(),
rev.to_string(),
],
)
}
pub fn classify_stderr(stderr: &str, ref_name: Option<&str>) -> BabyError {
let s = stderr.trim();
if let Some(name) = ref_name
&& (s.contains("not a tree") || s.contains("Not a tree"))
{
return BabyError::RefNotFound(name.to_string());
}
if s.contains("ambiguous") && ref_name.is_some() {
return BabyError::AmbiguousRef {
query: ref_name.unwrap_or("").to_string(),
candidates: Vec::new(),
};
}
if s.contains("not found") || s.contains("Needed a single revision") {
if let Some(name) = ref_name {
return BabyError::RefNotFound(name.to_string());
}
return BabyError::RevisionNotFound(s.to_string());
}
BabyError::Spawn {
prog: "git".to_string(),
source: std::io::Error::other(s.to_string()),
stderr: s.to_string(),
}
}
pub fn classify_cmd_error(err: CmdError, ref_name: Option<&str>) -> BabyError {
match err {
CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, ref_name),
CmdError::Spawn { prog, source } => crate::share::classify_spawn_error(prog, source),
other => BabyError::from(other),
}
}
pub fn classify_for_each_ref_type(s: &str) -> RefType {
match s {
"tag" => RefType::Tag,
"commit" => RefType::Branch,
_ => RefType::Other,
}
}
pub fn parse_for_each_ref_line(line: &str, peel: bool) -> Result<RefInfo, BabyError> {
let mut parts = line.splitn(4, ' ');
let sha_hex = parts.next().unwrap_or("");
let kind = parts.next().unwrap_or("");
let name = match peel {
true => match parts.next() {
Some(n) => {
let peeled_hex = parts.next().unwrap_or("");
let sha = gix::ObjectId::from_hex(sha_hex.as_bytes()).map_err(|e| {
BabyError::Custom(format!("for-each-ref invalid oid `{}`: {}", sha_hex, e))
})?;
let peeled = if peeled_hex.is_empty() {
None
} else {
Some(gix::ObjectId::from_hex(peeled_hex.as_bytes()).map_err(|e| {
BabyError::Custom(format!(
"for-each-ref invalid peeled oid `{}`: {}",
peeled_hex, e
))
})?)
};
return Ok(RefInfo {
name: n.to_string(),
ref_type: classify_for_each_ref_type(kind),
sha,
peeled,
});
}
None => {
return Err(BabyError::Custom(format!(
"for-each-ref malformed line: {}",
line
)));
}
},
false => match parts.next() {
Some(n) => n,
None => {
return Err(BabyError::Custom(format!(
"for-each-ref malformed line: {}",
line
)));
}
},
};
let sha = gix::ObjectId::from_hex(sha_hex.as_bytes())
.map_err(|e| BabyError::Custom(format!("for-each-ref invalid oid `{}`: {}", sha_hex, e)))?;
Ok(RefInfo {
name: name.to_string(),
ref_type: classify_for_each_ref_type(kind),
sha,
peeled: None,
})
}
pub fn parse_for_each_ref_output(stdout: &[u8], peel: bool) -> Result<Vec<RefInfo>, BabyError> {
let text = std::str::from_utf8(stdout)
.map_err(|e| BabyError::Custom(format!("for-each-ref non-utf8: {}", e)))?;
let mut out = Vec::new();
for line in text.lines() {
if line.is_empty() {
continue;
}
out.push(parse_for_each_ref_line(line, peel)?);
}
Ok(out)
}
pub fn encode_atomic_update(updates: &[RefUpdate]) -> Result<Vec<u8>, BabyError> {
let mut buf = Vec::new();
for u in updates {
validate_ref_update(u)?;
buf.extend_from_slice(b"update ");
buf.extend_from_slice(u.name.as_bytes());
buf.push(b' ');
buf.extend_from_slice(u.new_sha.to_string().as_bytes());
if let Some(o) = u.old_sha {
buf.push(b' ');
buf.extend_from_slice(o.to_string().as_bytes());
}
buf.push(b'\n');
}
buf.extend_from_slice(b"prepare\ncommit\n");
Ok(buf)
}
pub fn encode_atomic_delete(names: &[String]) -> Result<Vec<u8>, BabyError> {
let mut buf = Vec::new();
for n in names {
validate_ref_name(n)?;
buf.extend_from_slice(b"delete ");
buf.extend_from_slice(n.as_bytes());
buf.push(b'\n');
}
buf.extend_from_slice(b"prepare\ncommit\n");
Ok(buf)
}

5
src/refs/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::{ListRefsOptions, RefInfo, RefType, RefUpdate};

33
src/refs/types.rs Normal file
View File

@ -0,0 +1,33 @@
use gix::ObjectId;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefType {
Branch,
Tag,
Remote,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefInfo {
pub name: String,
pub ref_type: RefType,
pub sha: ObjectId,
pub peeled: Option<ObjectId>,
}
#[derive(Debug, Clone, Default)]
pub struct ListRefsOptions {
pub patterns: Vec<String>,
pub exclude_patterns: Vec<String>,
pub peel_tags: bool,
pub limit: Option<u32>,
pub start_after: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RefUpdate {
pub name: String,
pub new_sha: ObjectId,
pub old_sha: Option<ObjectId>,
}

217
src/refs/usecase.rs Normal file
View File

@ -0,0 +1,217 @@
use gix::ObjectId;
use crate::GitBaby;
use crate::error::BabyError;
use crate::refs::helper;
use crate::refs::types::{ListRefsOptions, RefInfo, RefUpdate};
use crate::share;
impl GitBaby {
pub async fn list_refs(&self, opts: ListRefsOptions) -> Result<Vec<RefInfo>, BabyError> {
helper::validate_list_refs_options(&opts)?;
let mut cmd = self
.spawn_refs_cmd(|dir, env| helper::build_list_refs_cmd(dir, env, &opts))
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, None))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
None,
));
}
helper::parse_for_each_ref_output(&output.stdout, opts.peel_tags)
}
pub async fn ref_exists(&self, name: &str) -> Result<bool, BabyError> {
share::validate_ref_name(name)?;
let mut cmd = self
.spawn_refs_cmd(|dir, env| helper::build_show_ref_cmd(dir, env, name))
.await?;
match cmd.run().await {
Ok(out) => Ok(out.status.success()),
Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => {
let s = stderr.trim();
if s.contains("not found") || s.contains("does not exist") || s.is_empty() {
Ok(false)
} else {
Err(helper::classify_stderr(s, Some(name)))
}
}
Err(e) => Err(helper::classify_cmd_error(e, Some(name))),
}
}
pub async fn resolve_revision(&self, rev: &str) -> Result<ObjectId, BabyError> {
share::validate_revision_non_empty(rev)?;
let mut cmd = self
.spawn_refs_cmd(|dir, env| helper::build_revspec_cmd(dir, env, rev))
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, Some(rev)))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
Some(rev),
));
}
let text = String::from_utf8_lossy(&output.stdout);
let hex = text.trim();
gix::ObjectId::from_hex(hex.as_bytes()).map_err(|e| {
BabyError::Custom(format!("rev-parse returned invalid oid `{}`: {}", hex, e))
})
}
pub async fn update_ref(
&self,
name: &str,
new_sha: ObjectId,
old_sha: Option<ObjectId>,
) -> Result<(), BabyError> {
let update = RefUpdate {
name: name.to_string(),
new_sha,
old_sha,
};
helper::validate_ref_update(&update)?;
let old_str = update.old_sha.map(|o| o.to_string());
let mut cmd = self
.spawn_refs_cmd(|dir, env| {
helper::build_update_ref_cmd(
dir,
env,
&update.name,
&update.new_sha.to_string(),
old_str.as_deref(),
false,
)
})
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, Some(&update.name)))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
Some(&update.name),
));
}
Ok(())
}
pub async fn delete_ref(&self, name: &str) -> Result<(), BabyError> {
share::validate_ref_name(name)?;
let mut cmd = self
.spawn_refs_cmd(|dir, env| helper::build_delete_ref_cmd(dir, env, name, false))
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, Some(name)))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
Some(name),
));
}
Ok(())
}
pub async fn find_refs_by_oid(&self, oid: ObjectId) -> Result<Vec<RefInfo>, BabyError> {
let mut cmd = self
.spawn_refs_cmd(|dir, env| {
helper::build_find_refs_by_oid_cmd(dir, env, &oid.to_string())
})
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, None))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
None,
));
}
helper::parse_for_each_ref_output(&output.stdout, false)
}
pub async fn update_refs_atomic(&self, updates: Vec<RefUpdate>) -> Result<(), BabyError> {
if updates.is_empty() {
return Err(BabyError::Custom(
"update_refs_atomic requires at least one update".to_string(),
));
}
for u in &updates {
helper::validate_ref_update(u)?;
}
let stdin = helper::encode_atomic_update(&updates)?;
let mut cmd = self
.spawn_refs_cmd(|dir, env| helper::build_update_ref_cmd(dir, env, "", "", None, true))
.await?;
if let Err(e) = cmd.feed(&stdin).await {
return Err(helper::classify_cmd_error(e, None));
}
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, None))?;
if !output.status.success() {
return Err(BabyError::RefUpdateRejected(
String::from_utf8_lossy(&output.stderr).into_owned(),
));
}
Ok(())
}
pub async fn delete_refs_atomic(&self, names: Vec<String>) -> Result<(), BabyError> {
if names.is_empty() {
return Err(BabyError::Custom(
"delete_refs_atomic requires at least one name".to_string(),
));
}
for n in &names {
share::validate_ref_name(n)?;
}
let stdin = helper::encode_atomic_delete(&names)?;
let mut cmd = self
.spawn_refs_cmd(|dir, env| helper::build_delete_ref_cmd(dir, env, "", true))
.await?;
if let Err(e) = cmd.feed(&stdin).await {
return Err(helper::classify_cmd_error(e, None));
}
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, None))?;
if !output.status.success() {
return Err(BabyError::RefUpdateRejected(
String::from_utf8_lossy(&output.stderr).into_owned(),
));
}
Ok(())
}
async fn spawn_refs_cmd<F>(&self, builder: F) -> Result<crate::command::cmd::Cmd, BabyError>
where
F: FnOnce(&std::path::Path, crate::command::env::Env) -> crate::command::cmd::Cmd,
{
let dir = self
.facade
.git_repo_dir()
.await
.map_err(share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(share::facade_error)?;
let env = share::build_env_safe(&alternates);
Ok(builder(&dir, env))
}
}

183
src/remote/helper.rs Normal file
View File

@ -0,0 +1,183 @@
use std::path::Path;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::error::BabyError;
use crate::remote::types::{RemoteOption, UpdateRemoteMirrorRequest};
pub fn build_get_url_cmd(caller: &str, dir: &Path, env: Env, name: &str) -> Result<Cmd, BabyError> {
crate::share::build_remote_op_cmd(
caller,
dir,
env,
crate::share::RemoteOp::GetUrl,
name,
None,
crate::share::RemoteMirror::Default,
)
}
pub fn build_add_cmd(
caller: &str,
dir: &Path,
env: Env,
name: &str,
url: &str,
mirror: RemoteOption,
) -> Result<Cmd, BabyError> {
crate::share::build_remote_op_cmd(
caller,
dir,
env,
crate::share::RemoteOp::Add,
name,
Some(url),
mirror.as_arg(),
)
}
pub fn build_remove_cmd(caller: &str, dir: &Path, env: Env, name: &str) -> Result<Cmd, BabyError> {
crate::share::build_remote_op_cmd(
caller,
dir,
env,
crate::share::RemoteOp::Remove,
name,
None,
crate::share::RemoteMirror::Default,
)
}
pub fn build_show_remote_cmd(
caller: &str,
dir: &Path,
env: Env,
name: &str,
) -> Result<Cmd, BabyError> {
crate::share::build_remote_op_cmd(
caller,
dir,
env,
crate::share::RemoteOp::Show,
name,
None,
crate::share::RemoteMirror::Default,
)
}
pub fn build_fetch_cmd(
caller: &str,
dir: &Path,
env: Env,
remote_path: &str,
commit_id: &str,
) -> Result<Cmd, BabyError> {
crate::share::build_fetch_cmd(
caller,
dir,
env,
remote_path,
&[commit_id.to_string()],
false,
false,
)
}
pub fn build_ls_remote_head_cmd(
caller: &str,
dir: &Path,
env: Env,
url: &str,
) -> Result<Cmd, BabyError> {
crate::share::build_ls_remote_cmd(caller, dir, env, url, &["HEAD".to_string()])
}
pub fn build_push_mirror_cmd(
caller: &str,
dir: &Path,
env: Env,
req: &UpdateRemoteMirrorRequest,
refspecs: Vec<String>,
) -> Result<Cmd, BabyError> {
crate::share::build_push_cmd(
caller,
dir,
env,
&req.url,
&refspecs,
!req.keep_divergent_refs,
)
}
pub fn classify_stderr_get_url(_stderr: &str) -> Option<BabyError> {
None
}
pub fn classify_stderr_add(stderr: &str, name: &str) -> BabyError {
if stderr.contains("already exists") {
BabyError::RemoteAlreadyExists(name.to_string())
} else if stderr.contains("invalid") {
BabyError::InvalidRemoteUrl(name.to_string())
} else {
BabyError::Custom(format!(
"git remote add failed for `{}`: {}",
name,
stderr.trim()
))
}
}
pub fn classify_stderr_rm(stderr: &str, name: &str) -> BabyError {
if stderr.contains("No such remote") || stderr.contains("no such remote") {
BabyError::RemoteNotFound(name.to_string())
} else {
BabyError::Custom(format!(
"git remote rm failed for `{}`: {}",
name,
stderr.trim()
))
}
}
pub fn classify_stderr_ls_remote(stderr: &str) -> BabyError {
if stderr.contains("not found") || stderr.contains("could not read") {
BabyError::InvalidRemoteUrl(stderr.trim().to_string())
} else {
BabyError::Custom(format!("git ls-remote failed: {}", stderr.trim()))
}
}
pub fn classify_stderr_show_remote(_stderr: &str) -> Option<BabyError> {
None
}
pub fn classify_stderr_push(stderr: &str) -> BabyError {
BabyError::Custom(format!("git push failed: {}", stderr.trim()))
}
pub fn classify_stderr_fetch(stderr: &str) -> BabyError {
BabyError::Custom(format!("git fetch failed: {}", stderr.trim()))
}
pub fn parse_head_branch_line(output: &[u8]) -> Option<String> {
let prefix = "HEAD branch: ";
for line in String::from_utf8_lossy(output).lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix(prefix) {
return Some(rest.to_string());
}
}
None
}
pub fn parse_oid_tab_head(output: &[u8]) -> Option<(gix::ObjectId, bool)> {
let text = String::from_utf8_lossy(output);
let first_line = text.lines().next()?;
let (oid_str, refname) = first_line.split_once('\t')?;
let refname = refname.trim();
if refname != "HEAD" {
return None;
}
let oid = crate::share::parse_object_id(oid_str).ok()?;
Some((oid, true))
}

7
src/remote/mod.rs Normal file
View File

@ -0,0 +1,7 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::{
FetchRemoteCommitResult, RemoteOption, UpdateRemoteMirrorRequest, UpdateRemoteMirrorResult,
};

35
src/remote/types.rs Normal file
View File

@ -0,0 +1,35 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RemoteOption {
#[default]
Default,
MirrorPush,
MirrorFetch,
}
impl RemoteOption {
pub fn as_arg(self) -> crate::share::RemoteMirror {
match self {
Self::Default => crate::share::RemoteMirror::Default,
Self::MirrorPush => crate::share::RemoteMirror::Push,
Self::MirrorFetch => crate::share::RemoteMirror::Fetch,
}
}
}
#[derive(Debug, Clone)]
pub struct UpdateRemoteMirrorRequest {
pub url: String,
pub refs: Vec<String>,
pub only_branches_matching: Vec<String>,
pub keep_divergent_refs: bool,
}
#[derive(Debug, Clone, Default)]
pub struct UpdateRemoteMirrorResult {
pub divergent_refs: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct FetchRemoteCommitResult {
pub fetched: bool,
}

225
src/remote/usecase.rs Normal file
View File

@ -0,0 +1,225 @@
use crate::GitBaby;
use crate::error::BabyError;
use crate::remote::helper;
use crate::remote::types::{
FetchRemoteCommitResult, RemoteOption, UpdateRemoteMirrorRequest, UpdateRemoteMirrorResult,
};
async fn build_env(
gitbaby: &GitBaby,
) -> Result<(std::path::PathBuf, crate::command::env::Env), BabyError> {
let dir = gitbaby
.facade
.git_repo_dir()
.await
.map_err(crate::share::facade_error)?;
let alternates = gitbaby
.facade
.git_alternate_object_directories()
.await
.map_err(crate::share::facade_error)?;
Ok((dir, crate::share::build_env_safe(&alternates)))
}
impl GitBaby {
pub async fn get_remote_address(&self, name: &str) -> Result<Option<String>, BabyError> {
if name.is_empty() {
return Err(BabyError::Custom(
"remote name must not be empty".to_string(),
));
}
let (dir, env) = build_env(self).await?;
let mut cmd = helper::build_get_url_cmd("gitbaby::remote", &dir, env, name)?;
match cmd.run().await {
Ok(output) => {
if output.status.success() {
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
return Ok(Some(url));
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("No such remote") || stderr.contains("no such remote") {
return Ok(None);
}
Err(BabyError::Custom(format!(
"git remote get-url failed: {}",
stderr.trim()
)))
}
Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => {
if stderr.contains("No such remote") || stderr.contains("no such remote") {
Ok(None)
} else {
Err(BabyError::Custom(format!(
"git remote get-url failed: {}",
stderr.trim()
)))
}
}
Err(e) => Err(BabyError::from(e)),
}
}
pub async fn add_remote(
&self,
name: &str,
url: &str,
opts: RemoteOption,
) -> Result<(), BabyError> {
if name.is_empty() {
return Err(BabyError::Custom(
"remote name must not be empty".to_string(),
));
}
if url.is_empty() {
return Err(BabyError::InvalidRemoteUrl(url.to_string()));
}
let (dir, env) = build_env(self).await?;
let mut cmd = helper::build_add_cmd("gitbaby::remote", &dir, env, name, url, opts)?;
let output = cmd.run_soft().await.map_err(BabyError::from)?;
if output.status.success() {
return Ok(());
}
Err(helper::classify_stderr_add(
&String::from_utf8_lossy(&output.stderr),
name,
))
}
pub async fn remove_remote(&self, name: &str) -> Result<(), BabyError> {
if name.is_empty() {
return Err(BabyError::Custom(
"remote name must not be empty".to_string(),
));
}
let (dir, env) = build_env(self).await?;
let mut cmd = helper::build_remove_cmd("gitbaby::remote", &dir, env, name)?;
let output = cmd.run_soft().await.map_err(BabyError::from)?;
if output.status.success() {
return Ok(());
}
Err(helper::classify_stderr_rm(
&String::from_utf8_lossy(&output.stderr),
name,
))
}
pub async fn fetch_remote_commit(
&self,
remote_repo_path: &str,
commit_id: &str,
) -> Result<FetchRemoteCommitResult, BabyError> {
if remote_repo_path.is_empty() {
return Err(BabyError::Custom(
"remote repository path must not be empty".to_string(),
));
}
if commit_id.is_empty() {
return Err(BabyError::Custom("commit id must not be empty".to_string()));
}
let (dir, env) = build_env(self).await?;
let mut cmd =
helper::build_fetch_cmd("gitbaby::remote", &dir, env, remote_repo_path, commit_id)?;
let output = cmd.run_soft().await.map_err(BabyError::from)?;
if output.status.success() {
return Ok(FetchRemoteCommitResult { fetched: true });
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not a git repository") {
return Err(BabyError::NotARepository(remote_repo_path.to_string()));
}
Err(helper::classify_stderr_fetch(&stderr))
}
pub async fn find_remote_repository(&self, url: &str) -> Result<bool, BabyError> {
if url.is_empty() {
return Err(BabyError::InvalidRemoteUrl(url.to_string()));
}
let dir = self
.facade
.git_repo_dir()
.await
.map_err(crate::share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(crate::share::facade_error)?;
let env = crate::share::build_env_safe(&alternates);
let mut cmd = helper::build_ls_remote_head_cmd("gitbaby::remote", &dir, env, url)?;
match cmd.run().await {
Ok(output) => {
if !output.status.success() {
return Ok(false);
}
Ok(helper::parse_oid_tab_head(&output.stdout).is_some())
}
Err(crate::command::error::CmdError::Spawn { .. }) => Ok(false),
Err(crate::command::error::CmdError::NonZeroExit { .. }) => Ok(false),
Err(crate::command::error::CmdError::Io { .. }) => Ok(false),
Err(e) => Err(helper::classify_stderr_ls_remote(&format!("{:?}", e))),
}
}
pub async fn find_remote_root_ref(&self, url: &str) -> Result<Option<String>, BabyError> {
if url.is_empty() {
return Err(BabyError::InvalidRemoteUrl(url.to_string()));
}
let dir = self
.facade
.git_repo_dir()
.await
.map_err(crate::share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(crate::share::facade_error)?;
let env = crate::share::build_env_safe(&alternates);
let mut cmd = helper::build_show_remote_cmd("gitbaby::remote", &dir, env, url)?;
let output = match cmd.run().await {
Ok(o) => o,
Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => {
if helper::classify_stderr_show_remote(&stderr).is_none() {
return Ok(None);
}
return Err(BabyError::Custom(format!(
"git remote show failed: {}",
stderr.trim()
)));
}
Err(e) => return Err(BabyError::from(e)),
};
if !output.status.success() {
return Ok(None);
}
match helper::parse_head_branch_line(&output.stdout) {
Some(ref_name) if ref_name == "(unknown)" => Ok(None),
Some(ref_name) => Ok(Some(ref_name)),
None => Ok(None),
}
}
pub async fn update_remote_mirror(
&self,
req: UpdateRemoteMirrorRequest,
) -> Result<UpdateRemoteMirrorResult, BabyError> {
if req.url.is_empty() {
return Err(BabyError::InvalidRemoteUrl(req.url.clone()));
}
if req.refs.is_empty() {
return Err(BabyError::Custom(
"update_remote_mirror requires at least one ref".to_string(),
));
}
let (dir, env) = build_env(self).await?;
let mut cmd =
helper::build_push_mirror_cmd("gitbaby::remote", &dir, env, &req, req.refs.clone())?;
let output = cmd.run_soft().await.map_err(BabyError::from)?;
if output.status.success() {
return Ok(UpdateRemoteMirrorResult::default());
}
Err(helper::classify_stderr_push(&String::from_utf8_lossy(
&output.stderr,
)))
}
}

12
src/repo.rs Normal file
View File

@ -0,0 +1,12 @@
use std::path::PathBuf;
use crate::error::BabyError;
#[async_trait::async_trait]
pub trait RepositoryFacade: Send + 'static {
async fn git_repo_dir(&self) -> Result<PathBuf, BabyError>;
async fn git_alternate_object_directories(&self) -> Result<Vec<PathBuf>, BabyError>;
async fn gix_repo(&self) -> Result<gix::Repository, BabyError>;
}

139
src/setup/helper.rs Normal file
View File

@ -0,0 +1,139 @@
use std::path::Path;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::error::BabyError;
use crate::setup::types::FastImportCommit;
pub fn build_init_cmd(
caller: &str,
dir: &Path,
env: Env,
path: &str,
bare: bool,
object_format: Option<&str>,
) -> Result<Cmd, BabyError> {
crate::share::build_init_cmd(caller, dir, env, path, bare, object_format)
}
pub fn build_fast_import_cmd(caller: &str, dir: &Path, env: Env) -> Cmd {
crate::share::build_fast_import_cmd(caller, dir, env)
}
pub fn build_is_repository_exists_cmd(caller: &str, path: &Path, env: Env) -> Cmd {
let args = vec![
"-C".to_string(),
path.to_string_lossy().into_owned(),
"rev-parse".to_string(),
"--is-inside-work-tree".to_string(),
];
crate::share::git_cmd(caller, path, env, None, args)
}
pub fn render_fast_import_stream(commits: &[FastImportCommit]) -> Result<Vec<u8>, BabyError> {
let mut buf = Vec::new();
for (i, c) in commits.iter().enumerate() {
validate_fast_import_field(&c.ref_name, "ref_name")?;
crate::share::path::validate_ref_name(&c.ref_name)?;
validate_fast_import_field(&c.committer_name, "committer_name")?;
if c.committer_name.contains('<') || c.committer_name.contains('>') {
return Err(BabyError::InvalidGitArg(format!(
"committer_name contains `<` or `>`: `{}`",
c.committer_name
)));
}
validate_fast_import_field(&c.committer_email, "committer_email")?;
if c.committer_email.contains('<') || c.committer_email.contains('>') {
return Err(BabyError::InvalidGitArg(format!(
"committer_email contains `<` or `>`: `{}`",
c.committer_email
)));
}
let msg = c
.message
.clone()
.unwrap_or_else(|| format!("commit {}", i + 1));
if msg.contains('\0') {
return Err(BabyError::InvalidGitArg("message contains NUL".to_string()));
}
let _ = std::fmt::Write::write_fmt(
&mut FastImportWriter(&mut buf),
format_args!("reset {}\n", c.ref_name),
);
let _ = std::fmt::Write::write_fmt(
&mut FastImportWriter(&mut buf),
format_args!(
"commit {}\nmark :{}\ncommitter {} <{}> {} {}\n",
c.ref_name,
i + 1,
c.committer_name,
c.committer_email,
c.committer_time,
format_tz(c.committer_tz_offset),
),
);
let _ = std::fmt::Write::write_fmt(
&mut FastImportWriter(&mut buf),
format_args!("data {}\n{}\n", msg.len(), msg),
);
for f in &c.files {
validate_fast_import_field(&f.path, "file.path")?;
crate::share::path::validate_local_no_escape(std::path::Path::new(&f.path))?;
let mode = f.mode.unwrap_or(0o100644);
let _ = std::fmt::Write::write_fmt(
&mut FastImportWriter(&mut buf),
format_args!("M {} inline {}\n", mode, f.path),
);
let _ = std::fmt::Write::write_fmt(
&mut FastImportWriter(&mut buf),
format_args!("data {}\n", f.content.len()),
);
buf.extend_from_slice(&f.content);
buf.push(b'\n');
}
}
buf.extend_from_slice(b"done\n");
Ok(buf)
}
fn validate_fast_import_field(value: &str, kind: &str) -> Result<(), BabyError> {
if value.is_empty() {
return Err(BabyError::InvalidGitArg(format!(
"fast-import {kind} is empty"
)));
}
if value.starts_with('-') {
return Err(BabyError::InvalidGitArg(format!(
"fast-import {kind} starts with `-`: `{}`",
value
)));
}
if value.contains('\n') || value.contains('\r') {
return Err(BabyError::InvalidGitArg(format!(
"fast-import {kind} contains newline: `{}`",
value
)));
}
Ok(())
}
struct FastImportWriter<'a>(&'a mut Vec<u8>);
impl std::fmt::Write for FastImportWriter<'_> {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
self.0.extend_from_slice(s.as_bytes());
Ok(())
}
}
pub fn format_tz(offset_seconds: i32) -> String {
let sign = if offset_seconds < 0 { '-' } else { '+' };
let abs = offset_seconds.unsigned_abs();
let hh = abs / 3600;
let mm = (abs % 3600) / 60;
format!("{}{:02}{:02}", sign, hh, mm)
}
pub fn classify_stderr_init(stderr: &str) -> String {
stderr.trim().to_string()
}

5
src/setup/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::{FastImportCommit, FastImportFile, ObjectFormat};

32
src/setup/types.rs Normal file
View File

@ -0,0 +1,32 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectFormat {
Sha1,
Sha256,
}
impl ObjectFormat {
pub fn as_str(self) -> &'static str {
match self {
Self::Sha1 => "sha1",
Self::Sha256 => "sha256",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct FastImportFile {
pub mode: Option<u32>,
pub path: String,
pub content: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct FastImportCommit {
pub ref_name: String,
pub message: Option<String>,
pub committer_name: String,
pub committer_email: String,
pub committer_time: i64,
pub committer_tz_offset: i32,
pub files: Vec<FastImportFile>,
}

83
src/setup/usecase.rs Normal file
View File

@ -0,0 +1,83 @@
use std::path::Path;
use crate::GitBaby;
use crate::command::env::Env;
use crate::error::BabyError;
use crate::setup::helper;
use crate::setup::types::{FastImportCommit, ObjectFormat};
fn empty_env() -> Env {
Env::new()
}
impl GitBaby {
pub async fn init_repository(
&self,
path: &str,
bare: bool,
object_format: Option<ObjectFormat>,
) -> Result<(), BabyError> {
if path.is_empty() {
return Err(BabyError::Custom(
"repository path must not be empty".to_string(),
));
}
if path.starts_with('-') {
return Err(BabyError::InvalidGitArg(format!(
"repository path starts with `-`: `{}`",
path
)));
}
let env = empty_env();
let of = object_format.map(|f| f.as_str());
let mut cmd =
helper::build_init_cmd("gitbaby::setup", Path::new(path), env, path, bare, of)?;
let output = cmd.run_soft().await.map_err(BabyError::from)?;
if output.status.success() {
return Ok(());
}
Err(BabyError::Custom(helper::classify_stderr_init(
&String::from_utf8_lossy(&output.stderr),
)))
}
pub async fn is_repository_exists(&self, path: &str) -> bool {
if path.is_empty() {
return false;
}
if path.starts_with('-') {
return false;
}
let env = empty_env();
let p = Path::new(path);
let mut cmd = helper::build_is_repository_exists_cmd("gitbaby::setup", p, env);
match cmd.run().await {
Ok(output) => output.status.success(),
Err(_) => false,
}
}
pub async fn fast_import(
&self,
dir: &Path,
commits: Vec<FastImportCommit>,
) -> Result<(), BabyError> {
if commits.is_empty() {
return Err(BabyError::FastImportFailed(
"no commits provided".to_string(),
));
}
let env = empty_env();
let stream = helper::render_fast_import_stream(&commits)?;
let mut cmd = helper::build_fast_import_cmd("gitbaby::setup", dir, env);
cmd.spawn().await.map_err(BabyError::from)?;
cmd.feed(&stream).await.map_err(BabyError::from)?;
let output = cmd.run_soft().await.map_err(BabyError::from)?;
if output.status.success() {
return Ok(());
}
Err(BabyError::FastImportFailed(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
))
}
}

520
src/share/cmd.rs Normal file
View File

@ -0,0 +1,520 @@
use std::io;
use std::path::Path;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::error::BabyError;
pub fn git_cmd(caller: &str, dir: &Path, env: Env, timeout: Option<u64>, args: Vec<String>) -> Cmd {
Cmd::new(
caller.to_string(),
"git",
args,
Vec::new(),
dir.to_path_buf(),
env,
timeout,
)
}
pub fn reject_starts_with_dash(value: &str, kind: &str) -> Result<(), BabyError> {
if value.starts_with('-') {
Err(BabyError::InvalidGitArg(format!(
"{} must not start with '-': `{}`",
kind, value
)))
} else {
Ok(())
}
}
pub fn validate_remote_url(url: &str) -> Result<(), BabyError> {
reject_starts_with_dash(url, "remote url")?;
let lower = url.to_ascii_lowercase();
if lower.starts_with("ext::")
|| lower.starts_with("ssh::")
|| lower.starts_with("git::")
|| lower.starts_with("remote-")
{
return Err(BabyError::InvalidRemoteUrl(url.to_string()));
}
let has_scheme = lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.starts_with("ssh://")
|| lower.starts_with("git://")
|| lower.starts_with("file://");
if !has_scheme {
// Allow scp-style: [user@]host:path or relative/absolute local paths
if url.contains("://") {
return Err(BabyError::InvalidRemoteUrl(url.to_string()));
}
if url.starts_with('-') {
return Err(BabyError::InvalidRemoteUrl(url.to_string()));
}
if url.contains('\0') || url.contains('\n') || url.contains('\r') {
return Err(BabyError::InvalidRemoteUrl(url.to_string()));
}
}
Ok(())
}
pub fn validate_revision(rev: &str) -> Result<(), BabyError> {
crate::share::path::validate_revision_non_empty(rev)?;
reject_starts_with_dash(rev, "revision")?;
if rev.contains('\0') || rev.contains('\n') || rev.contains('\r') {
return Err(BabyError::RevisionInvalid(rev.to_string()));
}
Ok(())
}
pub fn validate_config_key(key: &str) -> Result<(), BabyError> {
reject_starts_with_dash(key, "config key")?;
if key.is_empty() {
return Err(BabyError::InvalidGitArg("empty config key".to_string()));
}
let mut chars = key.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_alphabetic() || first == '_') {
return Err(BabyError::InvalidGitArg(format!(
"config key must start with letter or '_': `{}`",
key
)));
}
for ch in chars {
if !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '.' || ch == '_') {
return Err(BabyError::InvalidGitArg(format!(
"config key contains invalid character `{}` in `{}`",
ch, key
)));
}
}
if is_dangerous_config_key(key) {
return Err(BabyError::InvalidGitArg(format!(
"refusing to set dangerous config key `{}` via this API; use managed_config_* or explicit global opt-in",
key
)));
}
Ok(())
}
const DANGEROUS_CONFIG_KEY_PATTERNS: &[&str] = &[
"core.sshcommand",
"core.sshcommand.",
"core.hookspath",
"core.hookspath.",
"core.gitproxy",
"core.gitproxy.",
"credential.helper",
"credential.helper.",
"credential.username",
"credential.password",
"credential.token",
"http.extraheader",
"http.extraheader.",
"url.",
"include.path",
"includeif.",
"init.templatedir",
"protocol.file.allow",
"protocol.ext.allow",
"diff.",
"filter.",
"gc.auto",
"gc.autodetach",
"gpg.format",
"gpg.program",
"ssh.variant",
"alias.",
"remote.",
"branch.",
"tag.",
];
pub fn is_dangerous_config_key(key: &str) -> bool {
let lower = key.to_ascii_lowercase();
DANGEROUS_CONFIG_KEY_PATTERNS
.iter()
.any(|pat| lower == *pat || lower.starts_with(pat))
}
pub fn classify_spawn_error(prog: String, source: io::Error) -> BabyError {
BabyError::Spawn {
prog,
source,
stderr: String::new(),
}
}
pub fn for_each_ref_format(peel: bool) -> String {
if peel {
"%(objectname) %(objecttype) %(refname) %(*objectname)".to_string()
} else {
"%(objectname) %(objecttype) %(refname)".to_string()
}
}
pub fn build_for_each_ref_args(
patterns: &[String],
sort: Option<&str>,
limit: Option<u32>,
start_after: Option<&str>,
points_at: Option<&str>,
exclude: &[String],
peel: bool,
) -> Vec<String> {
let mut args = vec![
"for-each-ref".to_string(),
format!("--format={}", for_each_ref_format(peel)),
];
if let Some(s) = sort {
args.push("--sort".to_string());
args.push(s.to_string());
}
if let Some(l) = limit {
args.push("--count".to_string());
args.push(l.to_string());
}
if let Some(sa) = start_after {
args.push("--start-after".to_string());
args.push(sa.to_string());
}
if let Some(oid) = points_at {
args.push("--points-at".to_string());
args.push(oid.to_string());
}
for p in patterns {
args.push(p.clone());
}
for p in exclude {
args.push("--exclude".to_string());
args.push(p.clone());
}
args
}
pub enum CatFileSubcommand {
BatchCheck,
Batch,
Blob,
}
pub fn git_cat_file_cmd(
dir: &Path,
env: Env,
oid: &gix::ObjectId,
subcommand: CatFileSubcommand,
) -> Cmd {
let mut args = vec!["cat-file".to_string()];
match subcommand {
CatFileSubcommand::BatchCheck => {
args.push("--batch-check".to_string());
}
CatFileSubcommand::Batch => {
args.push("--batch".to_string());
}
CatFileSubcommand::Blob => {
args.push("blob".to_string());
args.push("--end-of-options".to_string());
args.push(oid.to_string());
}
}
git_cmd("gitbaby::blob", dir, env, None, args)
}
pub fn git_hash_object_cmd(dir: &Path, env: Env, path: Option<&str>) -> Cmd {
let mut args = vec![
"hash-object".to_string(),
"--stdin".to_string(),
"-w".to_string(),
];
if let Some(p) = path {
args.push("--path".to_string());
args.push(p.to_string());
}
git_cmd("gitbaby::blob", dir, env, None, args)
}
pub enum ConfigScope {
Global,
Local,
}
pub enum ConfigOp {
Get,
Set,
SetIfAbsent,
Add,
UnsetAll,
GetAll,
GetRegexp,
}
pub fn build_config_cmd(
caller: &str,
dir: &Path,
env: Env,
scope: ConfigScope,
op: ConfigOp,
key: &str,
value: Option<&str>,
) -> Result<Cmd, BabyError> {
validate_config_key(key)?;
if let Some(v) = value {
reject_starts_with_dash(v, "config value")?;
if v.contains('\0') {
return Err(BabyError::InvalidGitArg(format!(
"config value contains NUL: `{}`",
v
)));
}
}
let mut args = vec!["config".to_string()];
if matches!(scope, ConfigScope::Global) {
args.push("--global".to_string());
}
match op {
ConfigOp::Get => args.push("--get".to_string()),
ConfigOp::Set => {}
ConfigOp::SetIfAbsent => {}
ConfigOp::Add => args.push("--add".to_string()),
ConfigOp::UnsetAll => args.push("--unset-all".to_string()),
ConfigOp::GetAll => args.push("--get-all".to_string()),
ConfigOp::GetRegexp => args.push("--get-regexp".to_string()),
}
args.push(key.to_string());
if let Some(v) = value
&& matches!(op, ConfigOp::Set | ConfigOp::SetIfAbsent | ConfigOp::Add)
{
args.push(v.to_string());
}
Ok(git_cmd(caller, dir, env, None, args))
}
pub enum ArchiveFormatArg {
Tar,
TarGz,
TarBz2,
Zip,
}
pub fn build_archive_cmd(
caller: &str,
dir: &Path,
env: Env,
format: ArchiveFormatArg,
prefix: Option<&str>,
commit: &str,
paths: &[String],
) -> Result<Cmd, BabyError> {
validate_revision(commit)?;
if let Some(p) = prefix {
reject_starts_with_dash(p, "archive prefix")?;
if p.contains('\0') || p.contains('\n') || p.contains('\r') {
return Err(BabyError::InvalidGitArg(format!(
"archive prefix contains control character: `{}`",
p
)));
}
}
for p in paths {
reject_starts_with_dash(p, "archive path")?;
}
let mut args = vec!["archive".to_string()];
match format {
ArchiveFormatArg::Tar => args.push("--format=tar".to_string()),
ArchiveFormatArg::TarGz => args.push("--format=tar.gz".to_string()),
ArchiveFormatArg::TarBz2 => args.push("--format=tar.bz2".to_string()),
ArchiveFormatArg::Zip => args.push("--format=zip".to_string()),
}
if let Some(p) = prefix {
args.push(format!("--prefix={}", p));
}
args.push("--end-of-options".to_string());
args.push(commit.to_string());
if !paths.is_empty() {
args.push("--".to_string());
for p in paths {
args.push(p.clone());
}
}
Ok(git_cmd(caller, dir, env, None, args))
}
pub enum RemoteOp {
Add,
Remove,
GetUrl,
Show,
}
pub enum RemoteMirror {
Default,
Push,
Fetch,
}
pub fn build_remote_op_cmd(
caller: &str,
dir: &std::path::Path,
env: Env,
op: RemoteOp,
name: &str,
url: Option<&str>,
mirror: RemoteMirror,
) -> Result<Cmd, BabyError> {
reject_starts_with_dash(name, "remote name")?;
if name.is_empty() {
return Err(BabyError::InvalidGitArg("remote name is empty".to_string()));
}
if let Some(u) = url {
validate_remote_url(u)?;
}
let mut args = vec!["remote".to_string()];
match op {
RemoteOp::Add => args.push("add".to_string()),
RemoteOp::Remove => args.push("rm".to_string()),
RemoteOp::GetUrl => args.push("get-url".to_string()),
RemoteOp::Show => args.push("show".to_string()),
}
match mirror {
RemoteMirror::Default => {}
RemoteMirror::Push => args.push("--mirror=push".to_string()),
RemoteMirror::Fetch => args.push("--mirror=fetch".to_string()),
}
args.push("--end-of-options".to_string());
args.push(name.to_string());
if let Some(u) = url {
args.push(u.to_string());
}
Ok(git_cmd(caller, dir, env, None, args))
}
pub fn build_ls_remote_cmd(
caller: &str,
dir: &std::path::Path,
env: Env,
url: &str,
refs: &[String],
) -> Result<Cmd, BabyError> {
validate_remote_url(url)?;
for r in refs {
reject_starts_with_dash(r, "ref pattern")?;
}
let mut args = vec![
"ls-remote".to_string(),
"--end-of-options".to_string(),
url.to_string(),
];
for r in refs {
args.push(r.clone());
}
Ok(git_cmd(caller, dir, env, None, args))
}
pub fn build_init_cmd(
caller: &str,
dir: &std::path::Path,
env: Env,
path: &str,
bare: bool,
object_format: Option<&str>,
) -> Result<Cmd, BabyError> {
if path.starts_with('-') {
return Err(BabyError::InvalidGitArg(format!(
"init path must not start with '-': `{}`",
path
)));
}
if path.contains('\0') {
return Err(BabyError::InvalidGitArg(format!(
"init path contains NUL: `{}`",
path
)));
}
if let Some(of) = object_format {
reject_starts_with_dash(of, "object-format")?;
}
let mut args = vec!["init".to_string()];
if bare {
args.push("--bare".to_string());
}
if let Some(of) = object_format {
args.push(format!("--object-format={}", of));
}
args.push("--end-of-options".to_string());
args.push(path.to_string());
Ok(git_cmd(caller, dir, env, None, args))
}
pub fn build_fast_import_cmd(caller: &str, dir: &std::path::Path, env: Env) -> Cmd {
let args = vec![
"fast-import".to_string(),
"--force".to_string(),
"--done".to_string(),
];
git_cmd(caller, dir, env, None, args)
}
pub fn build_gc_cmd(caller: &str, dir: &std::path::Path, env: Env, prune_now: bool) -> Cmd {
let mut args = vec!["gc".to_string()];
if prune_now {
args.push("--prune=now".to_string());
}
git_cmd(caller, dir, env, None, args)
}
pub fn build_pack_refs_cmd(caller: &str, dir: &std::path::Path, env: Env) -> Cmd {
let args = vec!["pack-refs".to_string(), "--all".to_string()];
git_cmd(caller, dir, env, None, args)
}
pub fn build_fetch_cmd(
caller: &str,
dir: &std::path::Path,
env: Env,
remote_url: &str,
refspecs: &[String],
prune: bool,
tags: bool,
) -> Result<Cmd, BabyError> {
validate_remote_url(remote_url)?;
for r in refspecs {
reject_starts_with_dash(r, "refspec")?;
}
let mut args = vec!["fetch".to_string()];
if prune {
args.push("--prune".to_string());
}
if !tags {
args.push("--no-tags".to_string());
}
args.push("--end-of-options".to_string());
args.push(remote_url.to_string());
for r in refspecs {
args.push(r.clone());
}
Ok(git_cmd(caller, dir, env, None, args))
}
pub fn build_push_cmd(
caller: &str,
dir: &std::path::Path,
env: Env,
remote_url: &str,
refspecs: &[String],
force: bool,
) -> Result<Cmd, BabyError> {
validate_remote_url(remote_url)?;
for r in refspecs {
reject_starts_with_dash(r, "refspec")?;
}
let mut args = vec!["push".to_string()];
if force {
args.push("--force".to_string());
}
args.push("--end-of-options".to_string());
args.push(remote_url.to_string());
for r in refspecs {
args.push(r.clone());
}
Ok(git_cmd(caller, dir, env, None, args))
}

80
src/share/env.rs Normal file
View File

@ -0,0 +1,80 @@
use std::path::PathBuf;
use crate::command::env::Env;
/// Build an env for a git child process by **inheriting the whole process
/// environment**.
///
/// # Security
///
/// This forwards every current-process variable (including `GIT_SSH_COMMAND`,
/// `GIT_ASKPASS`, `LD_PRELOAD`, `*_TOKEN`, `*_SECRET`, proxies, …) into the
/// spawned `git` process. Prefer [`build_env_safe`] unless a specific variable
/// set must reach git (e.g. credential helpers you explicitly control).
pub fn build_env(alternates: &[PathBuf]) -> Env {
let mut env = Env::new().with_current_env();
set_alternate_object_dirs(&mut env, alternates);
env
}
/// Build an env for a git child process from an explicit allow-list of the
/// process environment plus the alternate object directories.
///
/// Only `PATH`, `HOME`, `LANG`, `LC_ALL`, `LC_CTYPE`, `USER`, `LOGNAME`, `TZ`
/// and `SSH_AUTH_SOCK` are forwarded; everything else is dropped. This keeps
/// CI tokens, cloud credentials, proxies and `GIT_*` overrides (such as a
/// hostile `GIT_SSH_COMMAND` or `GIT_DIR` set by an attacker) out of the
/// subprocess.
pub fn build_env_safe(alternates: &[PathBuf]) -> Env {
const ALLOWED: &[&str] = &[
"PATH",
"HOME",
"LANG",
"LC_ALL",
"LC_CTYPE",
"USER",
"LOGNAME",
"TZ",
"SSH_AUTH_SOCK",
"XDG_RUNTIME_DIR",
];
let mut env = Env::new();
for (k, v) in std::env::vars() {
if ALLOWED.iter().any(|a| *a == k) {
env.set(k, v);
}
}
// Explicitly drop git-override variables that could redirect where git
// reads config/objects from or how it shells out.
for dangerous in [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_SSH_COMMAND",
"GIT_SSH",
"GIT_ASKPASS",
"GIT_CONFIG",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_SYSTEM",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_EXEC_PATH",
"GIT_TEMPLATE_DIR",
"GIT_INDEX_FILE",
"GIT_CEILING_DIRECTORIES",
"LD_PRELOAD",
"DYLD_INSERT_LIBRARIES",
] {
env.unset(dangerous);
}
set_alternate_object_dirs(&mut env, alternates);
env
}
pub fn set_alternate_object_dirs(env: &mut Env, alternates: &[PathBuf]) {
if !alternates.is_empty()
&& let Ok(joined) = std::env::join_paths(alternates)
&& let Ok(s) = joined.into_string()
{
env.set("GIT_ALTERNATE_OBJECT_DIRECTORIES", s);
}
}

15
src/share/error.rs Normal file
View File

@ -0,0 +1,15 @@
use std::error::Error;
use crate::error::BabyError;
pub fn facade_error<E: Error + Send + Sync + 'static>(e: E) -> BabyError {
BabyError::Custom(format!("facade error: {}", e))
}
pub fn spawn_failure(prog: &str, source: std::io::Error) -> BabyError {
BabyError::Spawn {
prog: prog.to_string(),
source,
stderr: String::new(),
}
}

19
src/share/mod.rs Normal file
View File

@ -0,0 +1,19 @@
pub mod cmd;
pub mod env;
pub mod error;
pub mod path;
pub use cmd::{
ArchiveFormatArg, CatFileSubcommand, ConfigOp, ConfigScope, RemoteMirror, RemoteOp,
build_archive_cmd, build_config_cmd, build_fast_import_cmd, build_fetch_cmd,
build_for_each_ref_args, build_gc_cmd, build_init_cmd, build_ls_remote_cmd,
build_pack_refs_cmd, build_push_cmd, build_remote_op_cmd, classify_spawn_error,
for_each_ref_format, git_cat_file_cmd, git_cmd, git_hash_object_cmd, is_dangerous_config_key,
reject_starts_with_dash, validate_config_key, validate_remote_url, validate_revision,
};
pub use env::{build_env, build_env_safe, set_alternate_object_dirs};
pub use error::{facade_error, spawn_failure};
pub use path::{
parse_git_mode, parse_object_id, parse_object_map, to_string_lossy_owned,
validate_local_no_escape, validate_ref_name, validate_revision_non_empty,
};

105
src/share/path.rs Normal file
View File

@ -0,0 +1,105 @@
use std::path::{Component, Path};
use crate::error::BabyError;
pub fn validate_local_no_escape(path: &Path) -> Result<(), BabyError> {
if path.as_os_str().is_empty() {
return Err(BabyError::PathEscapesRepository);
}
if let Some(s) = path.to_str() {
if s.starts_with('-') {
return Err(BabyError::PathEscapesRepository);
}
} else {
return Err(BabyError::PathEscapesRepository);
}
if path.is_absolute() {
return Err(BabyError::PathEscapesRepository);
}
for comp in path.components() {
if matches!(comp, Component::ParentDir) {
return Err(BabyError::PathEscapesRepository);
}
}
Ok(())
}
pub fn validate_revision_non_empty(rev: &str) -> Result<(), BabyError> {
if rev.is_empty() {
return Err(BabyError::RevisionEmpty);
}
Ok(())
}
pub fn validate_ref_name(name: &str) -> Result<(), BabyError> {
if name.is_empty() || name == "@" {
return Err(BabyError::InvalidRefName(name.to_string()));
}
if name.starts_with('-') {
return Err(BabyError::InvalidRefName(name.to_string()));
}
if name.starts_with('/') || name.ends_with('/') {
return Err(BabyError::InvalidRefName(name.to_string()));
}
if name.starts_with('.') || name.ends_with('.') {
return Err(BabyError::InvalidRefName(name.to_string()));
}
if name.contains("..") || name.contains("//") || name.contains("@{") {
return Err(BabyError::InvalidRefName(name.to_string()));
}
if name.contains(".lock/") || name.ends_with(".lock") {
return Err(BabyError::InvalidRefName(name.to_string()));
}
if name.contains("/.") {
return Err(BabyError::InvalidRefName(name.to_string()));
}
for ch in name.chars() {
let b = ch as u32;
if b < 0o40 || b == 0o177 {
return Err(BabyError::InvalidRefName(name.to_string()));
}
match ch {
' ' | '~' | '^' | ':' | '?' | '*' | '[' | ']' => {
return Err(BabyError::InvalidRefName(name.to_string()));
}
_ => {}
}
}
Ok(())
}
pub fn to_string_lossy_owned(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
pub fn parse_git_mode(octal: &str) -> Result<u32, BabyError> {
u32::from_str_radix(octal, 8)
.map_err(|_| BabyError::Custom(format!("invalid git file mode `{}`", octal)))
}
pub fn parse_object_id(hex: &str) -> Result<gix::ObjectId, BabyError> {
gix::ObjectId::from_hex(hex.as_bytes())
.map_err(|source| BabyError::Custom(format!("invalid object id `{}`: {}", hex, source)))
}
pub fn parse_object_map(content: &str) -> Result<Vec<(gix::ObjectId, gix::ObjectId)>, BabyError> {
let mut pairs = Vec::new();
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty()
|| line.starts_with('#')
|| line.starts_with("old new")
{
continue;
}
let (old_hex, new_hex) = line
.split_once(' ')
.ok_or_else(|| BabyError::SubModuleParse {
line: 0,
reason: format!("object map line missing space: {}", line),
})?;
let old = parse_object_id(old_hex)?;
let new = parse_object_id(new_hex)?;
pairs.push((old, new));
}
Ok(pairs)
}

98
src/submodule/helper.rs Normal file
View File

@ -0,0 +1,98 @@
use std::path::Path;
use crate::command::env::Env;
use crate::error::BabyError;
use crate::submodule::types::SubModule;
pub async fn fetch_gitmodules_blob(
read_blob_at_path: impl FnOnce(&str) -> Result<Vec<u8>, BabyError>,
) -> Result<Option<Vec<u8>>, BabyError> {
match read_blob_at_path(".gitmodules") {
Ok(bytes) => Ok(Some(bytes)),
Err(BabyError::BlobNotFound { .. }) => Ok(None),
Err(e) => Err(e),
}
}
pub fn parse_blob_to_submodules(bytes: &[u8]) -> Result<Vec<SubModule>, BabyError> {
let text = std::str::from_utf8(bytes).map_err(|e| BabyError::SubModuleParse {
line: 0,
reason: format!("invalid UTF-8: {}", e),
})?;
parse_gitmodules(text)
}
pub fn parse_gitmodules(content: &str) -> Result<Vec<SubModule>, BabyError> {
let mut subs: Vec<SubModule> = Vec::new();
let mut current: Option<SubModule> = None;
for (idx, raw_line) in content.lines().enumerate() {
let line_no = idx as u64 + 1;
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') {
if let Some(s) = current.take() {
subs.push(s);
}
let end_pos = line.rfind(']').ok_or_else(|| BabyError::SubModuleParse {
line: line_no,
reason: format!("unterminated section header `{}`", line),
})?;
let inside = &line[1..end_pos];
let header = inside
.split(' ')
.next()
.ok_or_else(|| BabyError::SubModuleParse {
line: line_no,
reason: format!("malformed section header `{}`", line),
})?;
if header != "submodule" {
current = None;
continue;
}
let rest = inside
.strip_prefix("submodule")
.map(|r| r.trim_start())
.ok_or_else(|| BabyError::SubModuleParse {
line: line_no,
reason: format!("malformed submodule header `{}`", line),
})?;
let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
current = Some(SubModule {
name,
path: String::new(),
url: String::new(),
branch: String::new(),
});
continue;
}
let Some(ref mut s) = current else {
continue;
};
let (k, v) = line
.split_once('=')
.ok_or_else(|| BabyError::SubModuleParse {
line: line_no,
reason: format!("expected `key = value`, got `{}`", line),
})?;
let key = k.trim();
let value = v.trim().trim_matches('"').trim_matches('\'').to_string();
match key {
"path" => s.path = value,
"url" => s.url = value,
"branch" => s.branch = value,
_ => {}
}
}
if let Some(s) = current.take() {
subs.push(s);
}
Ok(subs)
}
pub fn lookup<'a>(modules: &'a [SubModule], name: &str) -> Option<&'a SubModule> {
modules.iter().find(|s| s.name == name)
}
pub fn _unused_dir_marker(_dir: &Path, _env: &Env) {}

5
src/submodule/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::SubModule;

7
src/submodule/types.rs Normal file
View File

@ -0,0 +1,7 @@
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SubModule {
pub name: String,
pub path: String,
pub url: String,
pub branch: String,
}

95
src/submodule/usecase.rs Normal file
View File

@ -0,0 +1,95 @@
use std::path::Path;
use crate::GitBaby;
use crate::error::BabyError;
use crate::share;
use crate::submodule::helper;
use crate::submodule::types::SubModule;
async fn read_blob_at_revision(
gitbaby: &GitBaby,
commit: &str,
blob_path: &str,
) -> Result<Option<Vec<u8>>, BabyError> {
share::validate_revision(commit)?;
share::validate_local_no_escape(Path::new(blob_path))?;
let dir = gitbaby
.facade
.git_repo_dir()
.await
.map_err(share::facade_error)?;
let alternates = gitbaby
.facade
.git_alternate_object_directories()
.await
.map_err(share::facade_error)?;
let env = share::build_env_safe(&alternates);
let args = vec![
"cat-file".to_string(),
"blob".to_string(),
"--end-of-options".to_string(),
format!("{}:{}", commit, blob_path),
];
let mut cmd = share::git_cmd("gitbaby::submodule", &dir, env, None, args);
match cmd.run().await {
Ok(output) => {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not a tree")
|| stderr.contains("does not exist")
|| stderr.contains("bad revision")
|| stderr.contains("could not read")
{
return Ok(None);
}
return Err(BabyError::SubModuleParse {
line: 0,
reason: format!("cat-file blob failed: {}", stderr.trim()),
});
}
Ok(Some(output.stdout))
}
Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => {
if stderr.contains("not a tree")
|| stderr.contains("does not exist")
|| stderr.contains("bad revision")
|| stderr.contains("could not read")
{
Ok(None)
} else {
Err(BabyError::SubModuleParse {
line: 0,
reason: format!("cat-file blob failed: {}", stderr.trim()),
})
}
}
Err(e) => Err(BabyError::from(e)),
}
}
impl GitBaby {
pub async fn list_submodules(&self, commit: &str) -> Result<Vec<SubModule>, BabyError> {
share::validate_revision_non_empty(commit)?;
match read_blob_at_revision(self, commit, ".gitmodules").await? {
Some(bytes) => helper::parse_blob_to_submodules(&bytes),
None => Ok(Vec::new()),
}
}
pub async fn get_submodule(
&self,
commit: &str,
name: &str,
) -> Result<Option<SubModule>, BabyError> {
share::validate_revision_non_empty(commit)?;
if name.is_empty() {
return Err(BabyError::Custom(
"submodule name must not be empty".to_string(),
));
}
let subs = self.list_submodules(commit).await?;
Ok(helper::lookup(&subs, name).cloned())
}
}
fn _unused(_p: &Path) {}

269
src/tags/helper.rs Normal file
View File

@ -0,0 +1,269 @@
use std::path::Path;
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::error::BabyError;
use crate::share;
use super::types::{
CreateTagOptions, ListTagsOptions, SigningFormat, SigningKey, TagType, TagVerifyResult,
};
pub const CALLER: &str = "gitbaby::tags";
pub fn validate_list_tags_options(opts: &ListTagsOptions) -> Result<(), BabyError> {
if opts.patterns.is_empty() {
return Err(BabyError::InvalidTagName(
"patterns must have at least one entry".to_string(),
));
}
for p in &opts.patterns {
if !p.starts_with("refs/tags/") {
return Err(BabyError::InvalidTagName(p.clone()));
}
share::validate_ref_name(p)?;
}
Ok(())
}
pub fn validate_create_tag_options(opts: &CreateTagOptions) -> Result<(), BabyError> {
if opts.name.is_empty() {
return Err(BabyError::InvalidTagName(opts.name.clone()));
}
share::validate_ref_name(&format!("refs/tags/{}", opts.name))?;
if opts.target.is_empty() {
return Err(BabyError::RevisionEmpty);
}
Ok(())
}
pub fn build_list_tags_cmd(dir: &Path, env: Env, opts: &ListTagsOptions) -> Cmd {
let args = share::build_for_each_ref_args(
&opts.patterns,
Some("creatordate"),
None,
None,
None,
&[],
true,
);
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_get_tag_id_cmd(dir: &Path, env: Env, name: &str) -> Cmd {
share::git_cmd(
CALLER,
dir,
env,
None,
vec![
"show-ref".to_string(),
"--tags".to_string(),
"--".to_string(),
name.to_string(),
],
)
}
pub fn build_cat_tag_cmd(dir: &Path, env: Env, oid: &str) -> Cmd {
share::git_cmd(
CALLER,
dir,
env,
None,
vec!["cat-file".to_string(), "-p".to_string(), oid.to_string()],
)
}
pub fn build_create_lightweight_tag_cmd(dir: &Path, env: Env, opts: &CreateTagOptions) -> Cmd {
share::git_cmd(
CALLER,
dir,
env,
None,
vec!["tag".to_string(), opts.name.clone(), opts.target.clone()],
)
}
pub fn build_create_annotated_tag_cmd(dir: &Path, env: Env, opts: &CreateTagOptions) -> Cmd {
let msg = opts.message.clone().unwrap_or_default();
share::git_cmd(
CALLER,
dir,
env,
None,
vec![
"tag".to_string(),
"-a".to_string(),
"-m".to_string(),
msg,
opts.name.clone(),
opts.target.clone(),
],
)
}
pub fn build_create_signed_tag_cmd(
dir: &Path,
env: Env,
opts: &CreateTagOptions,
key: &SigningKey,
) -> Cmd {
let (flag, key_arg) = match key.format {
SigningFormat::Ssh => ("-s".to_string(), None),
_ => ("-u".to_string(), Some(key.key_id.clone())),
};
let msg = opts.message.clone().unwrap_or_default();
let mut args = vec![
"tag".to_string(),
flag,
"-m".to_string(),
msg,
opts.name.clone(),
opts.target.clone(),
];
if let Some(ka) = key_arg {
args.insert(3, ka);
}
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_verify_tag_cmd(dir: &Path, env: Env, name: &str) -> Cmd {
share::git_cmd(
CALLER,
dir,
env,
None,
vec![
"verify-tag".to_string(),
"--raw".to_string(),
name.to_string(),
],
)
}
pub fn build_delete_tag_cmd(dir: &Path, env: Env, name: &str) -> Cmd {
share::git_cmd(
CALLER,
dir,
env,
None,
vec!["tag".to_string(), "-d".to_string(), name.to_string()],
)
}
pub fn classify_stderr(stderr: &str, name: &str) -> BabyError {
let s = stderr.trim();
if s.contains("already exists") {
return BabyError::TagAlreadyExists(name.to_string());
}
if s.contains("not found") || s.contains("does not exist") {
return BabyError::TagNotFound(name.to_string());
}
if s.contains("invalid") && (s.contains("tag") || s.contains("key")) {
return BabyError::InvalidTagName(name.to_string());
}
BabyError::Spawn {
prog: "git".to_string(),
source: std::io::Error::other(s.to_string()),
stderr: s.to_string(),
}
}
pub fn classify_cmd_error(err: crate::command::error::CmdError, name: &str) -> BabyError {
use crate::command::error::CmdError;
match err {
CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, name),
CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
other => BabyError::from(other),
}
}
pub fn parse_show_ref_tag(output: &str) -> Result<(String, String), BabyError> {
for line in output.lines() {
let s = line.trim();
if s.is_empty() {
continue;
}
let mut parts = s.split_whitespace();
let oid = match parts.next() {
Some(s) => s.to_string(),
None => continue,
};
let full = match parts.next() {
Some(s) => s.to_string(),
None => continue,
};
if full == "refs/tags/^{}" || full.ends_with("/^{}") {
continue;
}
if full.starts_with("refs/tags/") {
return Ok((oid, full));
}
}
Err(BabyError::TagNotFound(output.trim().to_string()))
}
pub fn parse_cat_tag_object(output: &str) -> (TagType, Option<ObjectId>) {
let mut tag_type = TagType::Lightweight;
let mut target: Option<ObjectId> = None;
for line in output.lines() {
if let Some(rest) = line.strip_prefix("type ") {
if rest.trim() == "tag" {
tag_type = TagType::Annotated;
}
} else if let Some(rest) = line.strip_prefix("object ")
&& let Ok(oid) = gix::ObjectId::from_hex(rest.trim().as_bytes())
{
target = Some(oid);
}
}
(tag_type, target)
}
pub fn parse_verify_tag_output(output: &str, stderr: &str) -> TagVerifyResult {
let combined = format!("{}\n{}", output, stderr);
let mut valid = false;
let mut signer: Option<String> = None;
let mut fingerprint: Option<String> = None;
let mut error_message: Option<String> = None;
if let Some(idx) = combined.find("error") {
let line_end = combined[idx..].find('\n').unwrap_or(combined.len() - idx);
error_message = Some(combined[idx..idx + line_end].trim().to_string());
}
for line in combined.lines() {
if line.starts_with("good ") || line.starts_with("valid ") {
valid = true;
}
if let Some(idx) = line.find("signer \"") {
let rest = &line[idx + 8..];
if let Some(end) = rest.find('"') {
signer = Some(rest[..end].to_string());
}
}
if let Some(idx) = line.find("fingerprint ") {
let rest = &line[idx + 12..];
let trimmed = rest.trim();
if !trimmed.is_empty() {
fingerprint = Some(
trimmed
.split_whitespace()
.next()
.unwrap_or(trimmed)
.to_string(),
);
}
}
}
TagVerifyResult {
valid,
signer,
fingerprint,
error_message,
}
}
use gix::ObjectId;

7
src/tags/mod.rs Normal file
View File

@ -0,0 +1,7 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::{
CreateTagOptions, ListTagsOptions, SigningFormat, SigningKey, TagInfo, TagType, TagVerifyResult,
};

50
src/tags/types.rs Normal file
View File

@ -0,0 +1,50 @@
use gix::ObjectId;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SigningFormat {
Ssh,
OpenGpg,
X509,
}
#[derive(Debug, Clone)]
pub struct SigningKey {
pub format: SigningFormat,
pub key_id: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TagType {
Lightweight,
Annotated,
}
#[derive(Debug, Clone)]
pub struct TagInfo {
pub name: String,
pub sha: ObjectId,
pub target_sha: ObjectId,
pub tag_type: TagType,
pub tagger: Option<crate::commit::types::Signature>,
pub message: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CreateTagOptions {
pub name: String,
pub target: String,
pub message: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ListTagsOptions {
pub patterns: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct TagVerifyResult {
pub valid: bool,
pub signer: Option<String>,
pub fingerprint: Option<String>,
pub error_message: Option<String>,
}

253
src/tags/usecase.rs Normal file
View File

@ -0,0 +1,253 @@
use crate::GitBaby;
use crate::commit::helper as commit_helper;
use crate::commit::types::Signature;
use crate::error::BabyError;
use crate::refs::types::{ListRefsOptions, RefType};
use crate::share;
use crate::tags::helper;
use crate::tags::types::{
CreateTagOptions, ListTagsOptions, SigningKey, TagInfo, TagType, TagVerifyResult,
};
impl GitBaby {
pub async fn list_tags(&self, opts: ListTagsOptions) -> Result<Vec<TagInfo>, BabyError> {
helper::validate_list_tags_options(&opts)?;
let refs_opts = ListRefsOptions {
patterns: opts.patterns.clone(),
peel_tags: true,
..Default::default()
};
let refs = self.list_refs(refs_opts).await?;
let mut tags = Vec::new();
for r in refs {
if !matches!(r.ref_type, RefType::Tag) {
continue;
}
let peeled = r.peeled.unwrap_or(r.sha);
tags.push(TagInfo {
name: r.name,
sha: r.sha,
target_sha: peeled,
tag_type: if r.peeled.is_some() {
TagType::Annotated
} else {
TagType::Lightweight
},
tagger: None,
message: None,
});
}
Ok(tags)
}
pub async fn find_tag(&self, name: &str) -> Result<TagInfo, BabyError> {
share::validate_ref_name(&format!("refs/tags/{}", name))?;
let mut cmd = self
.spawn_tags_cmd(|dir, env| helper::build_get_tag_id_cmd(dir, env, name))
.await?;
let output = match cmd.run().await {
Ok(o) => o,
Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => {
return Err(helper::classify_stderr(&stderr, name));
}
Err(e) => return Err(helper::classify_cmd_error(e, name)),
};
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
name,
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let (oid_str, full_name) = helper::parse_show_ref_tag(&stdout)?;
let oid = gix::ObjectId::from_hex(oid_str.as_bytes())
.map_err(|e| BabyError::Custom(format!("tag oid parse: {}", e)))?;
let mut cmd2 = self
.spawn_tags_cmd(|dir, env| helper::build_cat_tag_cmd(dir, env, &oid_str))
.await?;
let cat_output = cmd2
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, name))?;
let cat_stdout = String::from_utf8_lossy(&cat_output.stdout);
let (tag_type, target) = helper::parse_cat_tag_object(&cat_stdout);
let target_sha = target
.ok_or_else(|| BabyError::Custom(format!("tag `{}` has no target object", name)))?;
let (tagger, message) = parse_annotated_payload(&cat_stdout);
Ok(TagInfo {
name: full_name
.strip_prefix("refs/tags/")
.unwrap_or(&full_name)
.trim_end_matches("^{}")
.to_string(),
sha: oid,
target_sha,
tag_type,
tagger,
message,
})
}
pub async fn tag_exists(&self, name: &str) -> Result<bool, BabyError> {
share::validate_ref_name(&format!("refs/tags/{}", name))?;
let mut cmd = self
.spawn_tags_cmd(|dir, env| helper::build_get_tag_id_cmd(dir, env, name))
.await?;
match cmd.run().await {
Ok(out) => Ok(out.status.success()),
Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => {
let s = stderr.trim();
if s.contains("not found") || s.is_empty() {
Ok(false)
} else {
Err(helper::classify_stderr(s, name))
}
}
Err(e) => Err(helper::classify_cmd_error(e, name)),
}
}
pub async fn create_lightweight_tag(&self, opts: CreateTagOptions) -> Result<(), BabyError> {
helper::validate_create_tag_options(&opts)?;
let mut cmd = self
.spawn_tags_cmd(|dir, env| helper::build_create_lightweight_tag_cmd(dir, env, &opts))
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, &opts.name))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
&opts.name,
));
}
Ok(())
}
pub async fn create_annotated_tag(&self, opts: CreateTagOptions) -> Result<(), BabyError> {
helper::validate_create_tag_options(&opts)?;
if opts.message.is_none() {
return Err(BabyError::Custom(
"annotated tag requires message".to_string(),
));
}
let mut cmd = self
.spawn_tags_cmd(|dir, env| helper::build_create_annotated_tag_cmd(dir, env, &opts))
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, &opts.name))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
&opts.name,
));
}
Ok(())
}
pub async fn create_signed_tag(
&self,
opts: CreateTagOptions,
key: SigningKey,
) -> Result<(), BabyError> {
helper::validate_create_tag_options(&opts)?;
if opts.message.is_none() {
return Err(BabyError::Custom("signed tag requires message".to_string()));
}
let mut cmd = self
.spawn_tags_cmd(|dir, env| helper::build_create_signed_tag_cmd(dir, env, &opts, &key))
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, &opts.name))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
&opts.name,
));
}
Ok(())
}
pub async fn verify_tag_signature(&self, name: &str) -> Result<TagVerifyResult, BabyError> {
share::validate_ref_name(&format!("refs/tags/{}", name))?;
let mut cmd = self
.spawn_tags_cmd(|dir, env| helper::build_verify_tag_cmd(dir, env, name))
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, name))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let result = helper::parse_verify_tag_output(&stdout, &stderr);
Ok(result)
}
pub async fn delete_tag(&self, name: &str) -> Result<(), BabyError> {
share::validate_ref_name(&format!("refs/tags/{}", name))?;
let mut cmd = self
.spawn_tags_cmd(|dir, env| helper::build_delete_tag_cmd(dir, env, name))
.await?;
let output = cmd
.run()
.await
.map_err(|e| helper::classify_cmd_error(e, name))?;
if !output.status.success() {
return Err(helper::classify_stderr(
&String::from_utf8_lossy(&output.stderr),
name,
));
}
Ok(())
}
async fn spawn_tags_cmd<F>(&self, builder: F) -> Result<crate::command::cmd::Cmd, BabyError>
where
F: FnOnce(&std::path::Path, crate::command::env::Env) -> crate::command::cmd::Cmd,
{
let dir = self
.facade
.git_repo_dir()
.await
.map_err(share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(share::facade_error)?;
let env = share::build_env_safe(&alternates);
Ok(builder(&dir, env))
}
}
fn parse_annotated_payload(s: &str) -> (Option<Signature>, Option<String>) {
let mut tagger: Option<Signature> = None;
let mut message: Option<String> = None;
let mut in_message = false;
let mut msg_buf = String::new();
for line in s.lines() {
if in_message {
msg_buf.push_str(line);
msg_buf.push('\n');
continue;
}
if line.is_empty() {
in_message = true;
continue;
}
if let Some(rest) = line.strip_prefix("tagger ")
&& let Ok(sig) = commit_helper::parse_signature_line(rest)
{
tagger = Some(sig);
}
}
if !msg_buf.is_empty() {
message = Some(msg_buf.trim_end().to_string());
}
(tagger, message)
}

253
src/tree/helper.rs Normal file
View File

@ -0,0 +1,253 @@
use std::path::{Path, PathBuf};
use crate::command::cmd::Cmd;
use crate::command::env::Env;
use crate::commit::types::TreeEntryMode;
use crate::error::BabyError;
use crate::share;
use crate::tree::types::{DiffTreesOptions, TreeDiff, TreeDiffStatus, WalkTreeOptions};
pub const CALLER: &str = "gitbaby::tree";
pub const EMPTY_TREE_OID_HEX: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
pub fn empty_tree_oid() -> Result<gix::ObjectId, BabyError> {
gix::ObjectId::from_hex(EMPTY_TREE_OID_HEX.as_bytes())
.map_err(|e| BabyError::Custom(format!("empty tree oid parse: {}", e)))
}
pub fn build_walk_tree_cmd(
dir: &Path,
env: Env,
revision: &str,
path: &Path,
opts: &WalkTreeOptions,
) -> Cmd {
let mut args = vec!["ls-tree".to_string()];
if opts.recurse {
args.push("-r".to_string());
}
if opts.show_trees {
args.push("-t".to_string());
}
args.push("-l".to_string());
args.push(revision.to_string());
args.push("--".to_string());
args.push(share::to_string_lossy_owned(path));
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_diff_trees_cmd(
dir: &Path,
env: Env,
old_rev: &str,
new_rev: &str,
path: &Path,
opts: &DiffTreesOptions,
) -> Cmd {
let mut args = vec!["diff-tree".to_string()];
if opts.recurse {
args.push("-r".to_string());
}
if opts.detect_renames {
args.push("-M".to_string());
}
if opts.detect_copies {
args.push("-C".to_string());
}
args.push("--no-commit-id".to_string());
args.push("-z".to_string());
args.push("--diff-filter=ADMRT".to_string());
args.push(format!("{}..{}", old_rev, new_rev));
args.push("--".to_string());
args.push(share::to_string_lossy_owned(path));
share::git_cmd(CALLER, dir, env, None, args)
}
pub fn build_commit_tree_cmd(
dir: &Path,
env: Env,
tree_oid: &str,
parents: &[String],
signing_key_id: Option<&str>,
gpg_format: Option<&str>,
no_gpg_sign: bool,
) -> Result<Cmd, BabyError> {
share::validate_revision(tree_oid)?;
for p in parents {
share::validate_revision(p)?;
}
let mut args = vec![
"commit-tree".to_string(),
"--end-of-options".to_string(),
tree_oid.to_string(),
];
for p in parents {
args.push("-p".to_string());
args.push(p.clone());
}
if let Some(key_id) = signing_key_id {
share::reject_starts_with_dash(key_id, "signing key id")?;
if !key_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | '='))
{
return Err(BabyError::InvalidGitArg(format!(
"signing key id contains invalid characters: `{}`",
key_id
)));
}
if let Some(format) = gpg_format {
share::reject_starts_with_dash(format, "gpg format")?;
match format {
"openpgp" | "x509" | "ssh" => {
args.push("-c".to_string());
args.push(format!("gpg.format={}", format));
}
_ => {
return Err(BabyError::InvalidGitArg(format!(
"unsupported gpg format `{}` (expected openpgp|x509|ssh)",
format
)));
}
}
}
args.push(format!("-S{}", key_id));
}
if no_gpg_sign {
args.push("--no-gpg-sign".to_string());
}
Ok(share::git_cmd(CALLER, dir, env, None, args))
}
pub fn build_rev_list_latest_cmd(dir: &Path, env: Env, revision: &str, path: &Path) -> Cmd {
share::git_cmd(
CALLER,
dir,
env,
None,
vec![
"rev-list".to_string(),
"-1".to_string(),
revision.to_string(),
"--".to_string(),
share::to_string_lossy_owned(path),
],
)
}
pub fn classify_stderr(stderr: &str) -> BabyError {
let s = stderr.trim();
if s.contains("Not a tree") || s.contains("not a tree") {
return BabyError::NotATree {
revision: s.to_string(),
path: PathBuf::new(),
};
}
BabyError::Spawn {
prog: "git".to_string(),
source: std::io::Error::other(s.to_string()),
stderr: s.to_string(),
}
}
pub fn classify_cmd_error(err: crate::command::error::CmdError) -> BabyError {
use crate::command::error::CmdError;
match err {
CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr),
CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
other => BabyError::from(other),
}
}
pub fn parse_rev_list_first(stdout: &[u8]) -> Result<gix::ObjectId, BabyError> {
let s = std::str::from_utf8(stdout)
.map_err(|e| BabyError::Custom(format!("rev-list invalid utf8: {}", e)))?;
let hex = s.split_whitespace().next().unwrap_or("");
gix::ObjectId::from_hex(hex.as_bytes())
.map_err(|e| BabyError::Custom(format!("rev-list oid parse `{}`: {}", hex, e)))
}
pub fn parse_commit_tree_oid(stdout: &[u8]) -> Result<gix::ObjectId, BabyError> {
let s = std::str::from_utf8(stdout)
.map_err(|e| BabyError::Custom(format!("commit-tree invalid utf8: {}", e)))?;
gix::ObjectId::from_hex(s.trim().as_bytes())
.map_err(|e| BabyError::Custom(format!("commit-tree oid parse `{}`: {}", s.trim(), e)))
}
pub fn parse_mode(s: &str) -> Option<TreeEntryMode> {
if s.is_empty() || s == "0" {
return None;
}
TreeEntryMode::from_octal_str(s)
}
pub fn parse_diff_tree_z_output(stdout: &[u8]) -> Result<Vec<TreeDiff>, BabyError> {
let mut out = Vec::new();
let mut iter = stdout.split(|&b| b == 0);
while let Some(header) = iter.next() {
if header.is_empty() {
continue;
}
let header_str = match std::str::from_utf8(header) {
Ok(s) => s,
Err(_) => continue,
};
let mut parts = header_str.split(' ');
let old_mode = parts.next().unwrap_or("");
let new_mode = parts.next().unwrap_or("");
let old_oid_str = parts.next().unwrap_or("");
let new_oid_str = parts.next().unwrap_or("");
let status_byte = parts.next().unwrap_or("");
let old_oid =
if old_oid_str.len() >= 7 && old_oid_str.chars().all(|c| c.is_ascii_hexdigit()) {
gix::ObjectId::from_hex(old_oid_str.as_bytes()).ok()
} else {
None
};
let new_oid =
if new_oid_str.len() >= 7 && new_oid_str.chars().all(|c| c.is_ascii_hexdigit()) {
gix::ObjectId::from_hex(new_oid_str.as_bytes()).ok()
} else {
None
};
let path_bytes = match iter.next() {
Some(p) => p,
None => break,
};
let old_path = PathBuf::from(String::from_utf8_lossy(path_bytes).into_owned());
let new_path = match status_byte.chars().next().unwrap_or('M') {
'R' | 'C' => match iter.next() {
Some(p) => PathBuf::from(String::from_utf8_lossy(p).into_owned()),
None => old_path.clone(),
},
_ => old_path.clone(),
};
let status = match status_byte.chars().next().unwrap_or('M') {
'A' => TreeDiffStatus::Added,
'D' => TreeDiffStatus::Deleted,
'R' => TreeDiffStatus::Renamed,
'C' => TreeDiffStatus::Copied,
'T' => TreeDiffStatus::TypeChanged,
_ => TreeDiffStatus::Modified,
};
out.push(TreeDiff {
old_path: if status == TreeDiffStatus::Added {
None
} else {
Some(old_path.clone())
},
new_path: if status == TreeDiffStatus::Deleted {
None
} else {
Some(new_path)
},
old_mode: parse_mode(old_mode),
new_mode: parse_mode(new_mode),
old_oid,
new_oid,
status,
});
}
Ok(out)
}

5
src/tree/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod helper;
pub mod types;
pub mod usecase;
pub use types::{DiffTreesOptions, TreeDiff, TreeDiffStatus, WalkTreeOptions};

38
src/tree/types.rs Normal file
View File

@ -0,0 +1,38 @@
use gix::ObjectId;
use std::path::PathBuf;
use crate::commit::types::TreeEntryMode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TreeDiffStatus {
Added,
Deleted,
Modified,
TypeChanged,
Renamed,
Copied,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeDiff {
pub old_path: Option<PathBuf>,
pub new_path: Option<PathBuf>,
pub old_mode: Option<TreeEntryMode>,
pub new_mode: Option<TreeEntryMode>,
pub old_oid: Option<ObjectId>,
pub new_oid: Option<ObjectId>,
pub status: TreeDiffStatus,
}
#[derive(Debug, Clone, Default)]
pub struct WalkTreeOptions {
pub recurse: bool,
pub show_trees: bool,
}
#[derive(Debug, Clone, Default)]
pub struct DiffTreesOptions {
pub recurse: bool,
pub detect_renames: bool,
pub detect_copies: bool,
}

192
src/tree/usecase.rs Normal file
View File

@ -0,0 +1,192 @@
use std::path::Path;
use gix::ObjectId;
use crate::GitBaby;
use crate::commit::types::{CommitInfo, Signature, TreeEntry};
use crate::error::BabyError;
use crate::share;
use crate::tree::helper;
use crate::tree::types::{DiffTreesOptions, TreeDiff, WalkTreeOptions};
impl GitBaby {
pub async fn tree_latest_commit(
&self,
revision: &str,
path: &Path,
) -> Result<CommitInfo, BabyError> {
share::validate_revision_non_empty(revision)?;
share::validate_local_no_escape(path)?;
let mut cmd = self
.spawn_tree_cmd(|dir, env| {
Ok(helper::build_rev_list_latest_cmd(dir, env, revision, path))
})
.await?;
let output = cmd.run().await.map_err(helper::classify_cmd_error)?;
if !output.status.success() {
return Err(helper::classify_stderr(&String::from_utf8_lossy(
&output.stderr,
)));
}
let oid = helper::parse_rev_list_first(&output.stdout)?;
self.get_commit(&oid.to_string()).await
}
#[allow(clippy::too_many_arguments)]
pub async fn commit_tree(
&self,
tree_oid: ObjectId,
parents: Vec<String>,
author: &Signature,
committer: &Signature,
message: &str,
signing_key_id: Option<&str>,
gpg_format: Option<&str>,
no_gpg_sign: bool,
) -> Result<ObjectId, BabyError> {
let mut msg_buf = Vec::new();
msg_buf.extend_from_slice(message.as_bytes());
if !msg_buf.ends_with(b"\n") {
msg_buf.push(b'\n');
}
let mut cmd = self
.spawn_tree_cmd(|dir, env| {
let env = set_commit_env(env, author, committer);
helper::build_commit_tree_cmd(
dir,
env,
&tree_oid.to_string(),
&parents,
signing_key_id,
gpg_format,
no_gpg_sign,
)
})
.await?;
if let Err(e) = cmd.feed(&msg_buf).await {
return Err(helper::classify_cmd_error(e));
}
let output = cmd.run().await.map_err(helper::classify_cmd_error)?;
if !output.status.success() {
return Err(helper::classify_stderr(&String::from_utf8_lossy(
&output.stderr,
)));
}
helper::parse_commit_tree_oid(&output.stdout)
}
pub fn empty_tree_oid(&self) -> Result<ObjectId, BabyError> {
helper::empty_tree_oid()
}
pub async fn walk_tree(
&self,
revision: &str,
path: &Path,
opts: WalkTreeOptions,
) -> Result<Vec<TreeEntry>, BabyError> {
share::validate_revision_non_empty(revision)?;
share::validate_local_no_escape(path)?;
let mut cmd = self
.spawn_tree_cmd(|dir, env| {
Ok(helper::build_walk_tree_cmd(dir, env, revision, path, &opts))
})
.await?;
let output = cmd.run().await.map_err(helper::classify_cmd_error)?;
if !output.status.success() {
return Err(helper::classify_stderr(&String::from_utf8_lossy(
&output.stderr,
)));
}
crate::commit::helper::parse_tree_entries(&output.stdout)
}
pub async fn diff_trees(
&self,
old_rev: &str,
new_rev: &str,
path: &Path,
opts: DiffTreesOptions,
) -> Result<Vec<TreeDiff>, BabyError> {
share::validate_revision_non_empty(old_rev)?;
share::validate_revision_non_empty(new_rev)?;
share::validate_local_no_escape(path)?;
let mut cmd = self
.spawn_tree_cmd(|dir, env| {
Ok(helper::build_diff_trees_cmd(
dir, env, old_rev, new_rev, path, &opts,
))
})
.await?;
let output = cmd.run().await.map_err(helper::classify_cmd_error)?;
if !output.status.success() {
return Err(helper::classify_stderr(&String::from_utf8_lossy(
&output.stderr,
)));
}
helper::parse_diff_tree_z_output(&output.stdout)
}
async fn spawn_tree_cmd<F>(&self, builder: F) -> Result<crate::command::cmd::Cmd, BabyError>
where
F: FnOnce(
&std::path::Path,
crate::command::env::Env,
) -> Result<crate::command::cmd::Cmd, BabyError>,
{
let dir = self
.facade
.git_repo_dir()
.await
.map_err(share::facade_error)?;
let alternates = self
.facade
.git_alternate_object_directories()
.await
.map_err(share::facade_error)?;
let env = share::build_env_safe(&alternates);
builder(&dir, env)
}
}
fn set_commit_env(
mut env: crate::command::env::Env,
author: &Signature,
committer: &Signature,
) -> crate::command::env::Env {
let now_str = format_iso8601(time::OffsetDateTime::now_utc());
env.set("GIT_AUTHOR_NAME", author.name.clone());
env.set("GIT_AUTHOR_EMAIL", author.email.clone());
env.set("GIT_AUTHOR_DATE", now_str.clone());
env.set("GIT_COMMITTER_NAME", committer.name.clone());
env.set("GIT_COMMITTER_EMAIL", committer.email.clone());
env.set("GIT_COMMITTER_DATE", now_str);
env
}
fn format_iso8601(t: time::OffsetDateTime) -> String {
let (y, mo, d) = t.to_calendar_date();
let (h, mi, s) = {
let t2 = t - time::Duration::seconds(t.second() as i64);
let t2 = t2 - time::Duration::minutes(t.minute() as i64);
(t2.hour(), t2.minute(), t2.second())
};
let off = t.offset();
let off_secs = off.whole_seconds();
let sign = if off_secs >= 0 { '+' } else { '-' };
let abs = off_secs.unsigned_abs();
let oh = abs / 3600;
let om = (abs % 3600) / 60;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}",
y,
u8::from(mo),
d,
h,
mi,
s,
sign,
oh,
om
)
}

140
tests/cmd_run.rs Normal file
View File

@ -0,0 +1,140 @@
use gitbaby::command::cmd::Cmd;
use std::path::PathBuf;
macro_rules! unwrap_or_panic {
($expr:expr, $msg:literal) => {
match $expr {
Ok(v) => v,
Err(e) => panic!("{}: {:?}", $msg, e),
}
};
($expr:expr, $fmt:literal, $($arg:tt)*) => {
match $expr {
Ok(v) => v,
Err(e) => panic!("{}: {:?}", format_args!($fmt, $($arg)*), e),
}
};
}
#[tokio::test]
async fn run_echo_and_capture_output() {
let mut cmd = Cmd::new(
"test:echo",
"sh",
vec!["-c".to_string(), "echo hello".to_string()],
vec![],
PathBuf::from("."),
vec![],
None,
);
let out = unwrap_or_panic!(cmd.run().await, "run failed");
assert!(out.status.success(), "exit code should be 0");
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello");
}
#[tokio::test]
async fn run_with_non_zero_exit_returns_output() {
let mut cmd = Cmd::new(
"test:false",
"sh",
vec!["-c".to_string(), "echo oops 1>&2; exit 7".to_string()],
vec![],
PathBuf::from("."),
vec![],
None,
);
let res = cmd.run().await;
match res {
Ok(_) => panic!("run() with non-zero exit should fail"),
Err(gitbaby::command::error::CmdError::NonZeroExit { code, stderr, .. }) => {
assert_eq!(code, 7);
assert_eq!(stderr.trim(), "oops");
}
Err(e) => panic!("unexpected error: {:?}", e),
}
}
#[tokio::test]
async fn run_soft_keeps_non_zero_exit_in_output() {
let mut cmd = Cmd::new(
"test:false-soft",
"sh",
vec!["-c".to_string(), "echo oops 1>&2; exit 7".to_string()],
vec![],
PathBuf::from("."),
vec![],
None,
);
let out = unwrap_or_panic!(cmd.run_soft().await, "run_soft failed");
assert_eq!(out.status.code(), Some(7));
assert_eq!(String::from_utf8_lossy(&out.stderr).trim(), "oops");
}
#[tokio::test]
async fn feed_writes_to_stdin() {
let mut cmd = Cmd::new(
"test:cat",
"cat",
vec![],
vec![],
PathBuf::from("."),
vec![],
None,
);
unwrap_or_panic!(cmd.spawn().await, "spawn failed");
unwrap_or_panic!(cmd.feed(b"via-stdin").await, "feed failed");
let out = unwrap_or_panic!(cmd.run().await, "run failed");
assert_eq!(String::from_utf8_lossy(&out.stdout), "via-stdin");
}
#[tokio::test]
async fn env_vars_are_applied() {
let mut cmd = Cmd::new(
"test:env",
"sh",
vec!["-c".to_string(), "printf %s \"$GREETING\"".to_string()],
vec![],
PathBuf::from("."),
vec![("GREETING".to_string(), "hi".to_string())],
None,
);
let out = unwrap_or_panic!(cmd.run().await, "run failed");
assert!(out.status.success());
assert_eq!(String::from_utf8_lossy(&out.stdout), "hi");
}
#[tokio::test]
async fn config_args_prepend_to_command_line() {
let mut cmd = Cmd::new(
"test:cfg",
"echo",
vec!["world".to_string()],
vec!["ignored-no-flag-echo".to_string()],
PathBuf::from("."),
vec![],
None,
);
let rendered = format!("{cmd}");
assert_eq!(rendered, "echo ignored-no-flag-echo world");
let out = unwrap_or_panic!(cmd.run().await, "run failed");
assert_eq!(
String::from_utf8_lossy(&out.stdout).trim(),
"ignored-no-flag-echo world"
);
}
#[tokio::test]
async fn timeout_kills_long_running_process() {
let mut cmd = Cmd::new(
"test:sleep",
"sleep",
vec!["5".to_string()],
vec![],
PathBuf::from("."),
vec![],
Some(1),
);
let res = cmd.run().await;
assert!(res.is_err(), "timeout should produce an error");
}

160
tests/context.rs Normal file
View File

@ -0,0 +1,160 @@
use gitbaby::command::context::GitPipeContext;
struct MinimalCtx;
impl GitPipeContext for MinimalCtx {
fn cancel_pipe(&mut self) {}
}
struct StatefulCtx {
cancelled: bool,
current: Option<usize>,
finished: Vec<usize>,
progress: Vec<(usize, u64)>,
}
impl GitPipeContext for StatefulCtx {
fn cancel_pipe(&mut self) {
self.cancelled = true;
}
fn current_index(&self) -> Option<usize> {
self.current
}
fn on_command_finished(&mut self, index: usize) {
self.finished.push(index);
}
fn on_progress(&mut self, index: usize, bytes: u64) {
self.progress.push((index, bytes));
}
}
#[test]
fn default_current_index_is_none() {
let ctx = MinimalCtx;
assert_eq!(ctx.current_index(), None);
}
#[test]
fn default_hooks_are_noops() {
let mut ctx = MinimalCtx;
ctx.on_command_finished(0);
ctx.on_command_finished(7);
ctx.on_progress(2, 1024);
ctx.on_progress(5, 9999);
assert_eq!(ctx.current_index(), None);
}
#[test]
fn cancel_pipe_marks_cancelled_state() {
let mut ctx = StatefulCtx {
cancelled: false,
current: Some(0),
finished: vec![],
progress: vec![],
};
assert!(!ctx.cancelled);
ctx.cancel_pipe();
assert!(ctx.cancelled);
}
#[test]
fn on_command_finished_records_indices_in_order() {
let mut ctx = StatefulCtx {
cancelled: false,
current: None,
finished: vec![],
progress: vec![],
};
for i in [0_usize, 1, 2, 3] {
ctx.on_command_finished(i);
}
assert_eq!(ctx.finished, vec![0, 1, 2, 3]);
}
#[test]
fn on_progress_records_index_and_bytes_pairs() {
let mut ctx = StatefulCtx {
cancelled: false,
current: None,
finished: vec![],
progress: vec![],
};
ctx.on_progress(0, 0);
ctx.on_progress(0, 1024);
ctx.on_progress(1, 2048);
assert_eq!(ctx.progress, vec![(0, 0), (0, 1024), (1, 2048)]);
}
#[test]
fn current_index_returns_impl_specific_value() {
let ctx = StatefulCtx {
cancelled: false,
current: Some(3),
finished: vec![],
progress: vec![],
};
assert_eq!(ctx.current_index(), Some(3));
}
#[test]
fn current_index_can_be_none_even_when_active() {
let ctx = StatefulCtx {
cancelled: false,
current: None,
finished: vec![],
progress: vec![],
};
assert_eq!(ctx.current_index(), None);
}
#[test]
fn trait_object_dispatch_invokes_impl() {
let mut ctx = StatefulCtx {
cancelled: false,
current: Some(2),
finished: vec![],
progress: vec![],
};
{
let trait_obj: &mut dyn GitPipeContext = &mut ctx;
trait_obj.cancel_pipe();
trait_obj.on_command_finished(1);
trait_obj.on_progress(1, 42);
assert_eq!(trait_obj.current_index(), Some(2));
}
assert!(ctx.cancelled);
assert_eq!(ctx.finished, vec![1]);
assert_eq!(ctx.progress, vec![(1, 42)]);
}
#[test]
fn multiple_impls_are_independent_via_trait_objects() {
let mut a = StatefulCtx {
cancelled: false,
current: Some(0),
finished: vec![],
progress: vec![],
};
let mut b = StatefulCtx {
cancelled: false,
current: Some(1),
finished: vec![],
progress: vec![],
};
{
let trait_obj: &mut dyn GitPipeContext = &mut a;
trait_obj.cancel_pipe();
}
{
let trait_obj: &mut dyn GitPipeContext = &mut b;
trait_obj.on_command_finished(7);
}
assert!(a.cancelled);
assert!(!b.cancelled);
assert_eq!(b.finished, vec![7]);
assert_eq!(a.current_index(), Some(0));
assert_eq!(b.current_index(), Some(1));
}

137
tests/env.rs Normal file
View File

@ -0,0 +1,137 @@
use gitbaby::command::env::Env;
use std::sync::{Mutex, MutexGuard, PoisonError};
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn lock_or_recover() -> MutexGuard<'static, ()> {
match ENV_LOCK.lock() {
Ok(g) => g,
Err(PoisonError { .. }) => panic!("env lock poisoned"),
}
}
fn unique_key(suffix: &str) -> String {
format!("GITBABY_TEST_ENV_{suffix}")
}
unsafe fn set_var(key: &str, val: &str) {
unsafe { std::env::set_var(key, val) }
}
unsafe fn remove_var(key: &str) {
unsafe { std::env::remove_var(key) }
}
#[test]
fn from_current_env_includes_system_var() {
let _guard = lock_or_recover();
let key = unique_key("FROM_CUR");
unsafe { set_var(&key, "from-system") };
let env = Env::from_current_env();
assert_eq!(env.get(&key), Some("from-system"));
unsafe { remove_var(&key) };
}
#[test]
fn from_current_env_contains_path_like_var() {
let env = Env::from_current_env();
assert!(env.get("PATH").is_some() || env.get("Path").is_some() || !env.is_empty());
}
#[test]
fn set_overrides_system_var_after_from_current_env() {
let _guard = lock_or_recover();
let key = unique_key("OVERRIDE");
unsafe { set_var(&key, "system-value") };
let mut env = Env::from_current_env();
env.set(&key, "user-value");
assert_eq!(env.get(&key), Some("user-value"));
unsafe { remove_var(&key) };
}
#[test]
fn with_current_env_overrides_user_added_var() {
let _guard = lock_or_recover();
let key = unique_key("WC_OVERRIDE");
unsafe { set_var(&key, "system-wins") };
let env = Env::new().with(&key, "user-loses").with_current_env();
assert_eq!(env.get(&key), Some("system-wins"));
unsafe { remove_var(&key) };
}
#[test]
fn overlay_current_env_preserves_user_added_var() {
let _guard = lock_or_recover();
let key = unique_key("OVERLAY");
unsafe { set_var(&key, "system-value") };
let env = Env::new().with(&key, "user-keeps").overlay_current_env();
assert_eq!(env.get(&key), Some("user-keeps"));
unsafe { remove_var(&key) };
}
#[test]
fn overlay_current_env_fills_missing_keys() {
let _guard = lock_or_recover();
let mut iter = std::env::vars_os();
let known = match iter.next() {
Some(v) => v,
None => panic!("process should have at least one env var"),
};
let known_key = known.0.to_string_lossy().into_owned();
let env = Env::new().overlay_current_env();
assert!(env.get(&known_key).is_some());
}
#[test]
fn unset_removes_system_var_when_loaded() {
let _guard = lock_or_recover();
let key = unique_key("UNSET");
unsafe { set_var(&key, "temp") };
let mut env = Env::from_current_env();
assert_eq!(env.get(&key), Some("temp"));
env.unset(&key);
assert_eq!(env.get(&key), None);
unsafe { remove_var(&key) };
}
#[test]
fn from_current_env_is_cloneable() {
let _guard = lock_or_recover();
let key = unique_key("CLONE");
unsafe { set_var(&key, "v") };
let env = Env::from_current_env();
let copy = env.clone();
assert_eq!(env.get(&key), Some("v"));
assert_eq!(copy.get(&key), Some("v"));
unsafe { remove_var(&key) };
}
#[test]
fn set_after_from_current_env_does_not_grow_pairs_when_key_known() {
let _guard = lock_or_recover();
let key = unique_key("NOGROW");
unsafe { set_var(&key, "system") };
let mut env = Env::from_current_env();
let before = env.len();
env.set(&key, "user");
let after = env.len();
assert_eq!(before, after, "set 应替换而非新增");
assert_eq!(env.get(&key), Some("user"));
unsafe { remove_var(&key) };
}

169
tests/pipe.rs Normal file
View File

@ -0,0 +1,169 @@
use gitbaby::command::cmd::Cmd;
use gitbaby::command::context::GitPipeContext;
use gitbaby::command::pipe::Pipe;
use std::path::PathBuf;
use std::time::Duration;
fn make_cmd(caller: &str, prog: &str, arg: &str) -> Cmd {
Cmd::new(
caller,
prog,
vec![arg.to_string()],
vec![],
PathBuf::from("."),
vec![],
None,
)
}
fn must_some<T>(opt: Option<T>, msg: &str) -> T {
match opt {
Some(v) => v,
None => panic!("{msg}"),
}
}
#[test]
fn pipe_builder_sets_initial_fields() {
let pipe = Pipe::new("demo", PathBuf::from("/tmp/work"));
assert_eq!(pipe.name, "demo");
assert_eq!(pipe.dir, PathBuf::from("/tmp/work"));
assert_eq!(pipe.len(), 0);
assert!(pipe.is_empty());
assert!(!pipe.is_cancelled());
assert_eq!(pipe.timeout, None);
assert!(pipe.first().is_none());
assert!(pipe.last().is_none());
}
#[test]
fn pipe_push_grows_cmds_and_chains() {
let pipe = Pipe::new("chain", PathBuf::from("."))
.push(make_cmd("c1", "echo", "a"))
.push(make_cmd("c2", "echo", "b"));
assert_eq!(pipe.len(), 2);
assert!(!pipe.is_empty());
assert_eq!(
must_some(pipe.first(), "first must be Some").caller_info,
"c1"
);
assert_eq!(
must_some(pipe.last(), "last must be Some").caller_info,
"c2"
);
}
#[test]
fn pipe_extend_accepts_iterator() {
let cmds = vec![
make_cmd("a", "echo", "1"),
make_cmd("b", "echo", "2"),
make_cmd("c", "echo", "3"),
];
let pipe = Pipe::new("ext", PathBuf::from(".")).extend(cmds);
assert_eq!(pipe.len(), 3);
assert_eq!(must_some(pipe.last(), "last must be Some").caller_info, "c");
}
#[test]
fn pipe_with_dir_overrides_directory() {
let pipe = Pipe::new("d", PathBuf::from("/old")).with_dir(PathBuf::from("/new"));
assert_eq!(pipe.dir, PathBuf::from("/new"));
}
#[test]
fn pipe_with_timeout_sets_value() {
let pipe = Pipe::new("t", PathBuf::from(".")).with_timeout(5_000);
assert_eq!(pipe.timeout, Some(5_000));
}
#[test]
fn pipe_display_shows_name_and_joined_commands() {
let pipe = Pipe::new("pipe-name", PathBuf::from("."))
.push(make_cmd("c1", "git", "log"))
.push(make_cmd("c2", "git", "show"));
let rendered = format!("{pipe}");
assert_eq!(rendered, "pipe-name: git log | git show");
}
#[test]
fn pipe_display_when_empty() {
let pipe = Pipe::new("empty-pipe", PathBuf::from("."));
assert_eq!(format!("{pipe}"), "empty-pipe:<empty>");
}
#[test]
fn pipe_debug_includes_field_summary() {
let pipe = Pipe::new("dbg", PathBuf::from("."))
.with_timeout(42)
.push(make_cmd("c", "echo", "x"));
let rendered = format!("{pipe:?}");
assert!(rendered.contains("Pipe"));
assert!(rendered.contains("dbg"));
assert!(rendered.contains("42"));
assert!(rendered.contains("cancelled: false"));
}
#[test]
fn pipe_current_index_unset_when_empty() {
let pipe = Pipe::new("e", PathBuf::from("."));
assert_eq!(pipe.current_index(), None);
}
#[test]
fn pipe_current_index_after_push() {
let pipe = Pipe::new("e", PathBuf::from("."))
.push(make_cmd("c1", "echo", "a"))
.push(make_cmd("c2", "echo", "b"));
assert_eq!(pipe.current_index(), Some(1));
}
#[test]
fn pipe_cancel_sets_flag_and_drops_index() {
let mut pipe = Pipe::new("c", PathBuf::from("."))
.push(make_cmd("c1", "echo", "a"))
.push(make_cmd("c2", "echo", "b"));
assert!(!pipe.is_cancelled());
assert_eq!(pipe.current_index(), Some(1));
pipe.cancel_pipe();
assert!(pipe.is_cancelled());
assert_eq!(pipe.current_index(), None);
}
#[tokio::test]
async fn pipe_cancel_kills_running_children() {
let mut pipe = Pipe::new("kill", PathBuf::from(".")).push(Cmd::new(
"sleeper",
"sleep",
vec!["30".to_string()],
vec![],
PathBuf::from("."),
vec![],
None,
));
if let Err(e) = pipe.cmds[0].spawn().await {
panic!("spawn failed: {:?}", e);
}
pipe.cancel_pipe();
assert!(pipe.is_cancelled());
assert!(!pipe.spawn_allowed());
tokio::time::sleep(Duration::from_millis(200)).await;
// The child handle is handed to the reaper task; kill must be rejected
// for new spawns and the stale handle must no longer be owned by the Cmd.
assert!(
pipe.cmds[0].cmd.is_none(),
"cancel_pipe should take over the child handle"
);
}
#[tokio::test]
async fn pipe_cancel_is_safe_when_no_children_running() {
let mut pipe = Pipe::new("safe", PathBuf::from(".")).push(make_cmd("c", "echo", "x"));
pipe.cancel_pipe();
assert!(pipe.is_cancelled());
assert_eq!(pipe.current_index(), None);
}