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:
commit
680411c4fb
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
/target
|
||||||
|
**.iml
|
||||||
|
.idea
|
||||||
|
.claude
|
||||||
|
.codex
|
||||||
|
.opencode
|
||||||
|
.env*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
id_rsa*
|
||||||
|
id_ed25519*
|
||||||
38
AGENTS.md
Normal file
38
AGENTS.md
Normal 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
1930
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
Normal file
12
Cargo.toml
Normal 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
103
src/archive/helper.rs
Normal 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
7
src/archive/mod.rs
Normal 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
131
src/archive/types.rs
Normal 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
97
src/archive/usecase.rs
Normal 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
135
src/blame/helper.rs
Normal 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
6
src/blame/mod.rs
Normal 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
34
src/blame/types.rs
Normal 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
293
src/blame/usecase.rs
Normal 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
245
src/blob/helper.rs
Normal 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
9
src/blob/mod.rs
Normal 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
63
src/blob/types.rs
Normal 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
273
src/blob/usecase.rs
Normal 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
178
src/branch/helper.rs
Normal 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
5
src/branch/mod.rs
Normal 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
18
src/branch/types.rs
Normal 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
237
src/branch/usecase.rs
Normal 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
19
src/cleanup/helper.rs
Normal 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
5
src/cleanup/mod.rs
Normal 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
9
src/cleanup/types.rs
Normal 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
59
src/cleanup/usecase.rs
Normal 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
337
src/command/cmd.rs
Normal 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
11
src/command/context.rs
Normal 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
159
src/command/env.rs
Normal 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
205
src/command/error.rs
Normal 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
5
src/command/mod.rs
Normal 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
122
src/command/pipe.rs
Normal 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
578
src/commit/helper.rs
Normal 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
5
src/commit/mod.rs
Normal 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
99
src/commit/types.rs
Normal 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
238
src/commit/usecase.rs
Normal 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
122
src/compare/helper.rs
Normal 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
4
src/compare/mod.rs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
pub mod helper;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub use types::DivergeObject;
|
||||||
13
src/compare/types.rs
Normal file
13
src/compare/types.rs
Normal 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
69
src/compare/usecase.rs
Normal 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
152
src/config/helper.rs
Normal 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
5
src/config/mod.rs
Normal 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
25
src/config/types.rs
Normal 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
255
src/config/usecase.rs
Normal 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
147
src/conflict/helper.rs
Normal 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
8
src/conflict/mod.rs
Normal 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
57
src/conflict/types.rs
Normal 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
132
src/conflict/usecase.rs
Normal 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
471
src/diff/helper.rs
Normal 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
8
src/diff/mod.rs
Normal 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
96
src/diff/types.rs
Normal 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
407
src/diff/usecase.rs
Normal 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
277
src/error.rs
Normal 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
50
src/lib.rs
Normal 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
32
src/merge/helper.rs
Normal 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
5
src/merge/mod.rs
Normal 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
6
src/merge/types.rs
Normal 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
53
src/merge/usecase.rs
Normal 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
269
src/refs/helper.rs
Normal 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
5
src/refs/mod.rs
Normal 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
33
src/refs/types.rs
Normal 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
217
src/refs/usecase.rs
Normal 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
183
src/remote/helper.rs
Normal 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
7
src/remote/mod.rs
Normal 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
35
src/remote/types.rs
Normal 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
225
src/remote/usecase.rs
Normal 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
12
src/repo.rs
Normal 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
139
src/setup/helper.rs
Normal 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
5
src/setup/mod.rs
Normal 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
32
src/setup/types.rs
Normal 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
83
src/setup/usecase.rs
Normal 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
520
src/share/cmd.rs
Normal 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
80
src/share/env.rs
Normal 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
15
src/share/error.rs
Normal 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
19
src/share/mod.rs
Normal 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
105
src/share/path.rs
Normal 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
98
src/submodule/helper.rs
Normal 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
5
src/submodule/mod.rs
Normal 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
7
src/submodule/types.rs
Normal 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
95
src/submodule/usecase.rs
Normal 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
269
src/tags/helper.rs
Normal 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
7
src/tags/mod.rs
Normal 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
50
src/tags/types.rs
Normal 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
253
src/tags/usecase.rs
Normal 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
253
src/tree/helper.rs
Normal 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
5
src/tree/mod.rs
Normal 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
38
src/tree/types.rs
Normal 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
192
src/tree/usecase.rs
Normal 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
140
tests/cmd_run.rs
Normal 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
160
tests/context.rs
Normal 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
137
tests/env.rs
Normal 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
169
tests/pipe.rs
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user