chore(deps): vendor gitbaby into lib/gitbaby
Replace the private git dependency (gitea.gitdata.me/GitDataAi/GitBaby.git @ 69bd99a) with the source vendored under lib/gitbaby as a workspace member, so builds no longer fetch the crate from the network and the source can be edited in-tree. gitbaby now resolves as a path dependency and foyer is declared in [workspace.dependencies]. The vendored tree is byte-identical to 69bd99a except rustfmt formatting and clippy fixes in commit/helper.rs, diff/helper.rs and tree/usecase.rs; the upstream test suite (68 tests) passes.
ZhenYi committed
Added lib/gitbaby/src/archive/helper.rs lib/gitbaby/src/archive/helper.rs +324 −0 View lib/gitbaby/src/archive/helper.rs
@@ -0,0 +1,324 @@
1use std::io;
2use std::path::{Path, PathBuf};
3use std::pin::Pin;
4use std::task::{Context, Poll};
5
6use serde::Serialize;
7use tokio::io::AsyncWriteExt;
8
9use crate::archive::types::{ArchiveFormat, BundleRequest, GetArchiveRequest};
10use crate::command::cmd::Cmd;
11use crate::command::env::Env;
12use crate::error::BabyError;
13
14/// 缓存目录名,位于仓库根下(裸仓库无工作区,无污染风险)。
15const ARCHIVE_DIR_NAME: &str = "archive";
16
17/// 已打包产物在仓库内的落盘目录。
18pub fn archive_store_dir(repo_dir: &Path) -> PathBuf {
19 repo_dir.join(ARCHIVE_DIR_NAME)
20}
21
22/// 归档格式对应的文件扩展名(bundle 固定为 `bundle`)。
23pub fn format_ext(format: ArchiveFormat) -> &'static str {
24 match format {
25 ArchiveFormat::Tar => "tar",
26 ArchiveFormat::TarGz => "tar.gz",
27 ArchiveFormat::TarBz2 => "tar.bz2",
28 ArchiveFormat::Zip => "zip",
29 }
30}
31
32/// 缓存文件最终路径:`<repo>/archive/<key>.<ext>`。
33pub fn archive_file_path(repo_dir: &Path, key: &str, ext: &str) -> PathBuf {
34 archive_store_dir(repo_dir).join(format!("{}.{}", key, ext))
35}
36
37/// 写入过程中的临时路径:`<final>.part.<pid>`,pid 后缀避免并发冲突。
38pub fn tmp_archive_path(final_path: &Path) -> PathBuf {
39 let mut os = final_path.as_os_str().to_os_string();
40 os.push(format!(".part.{}", std::process::id()));
41 PathBuf::from(os)
42}
43
44fn sha1_hex(data: &[u8]) -> Result<String, BabyError> {
45 let mut hasher = gix::hash::hasher(gix::hash::Kind::Sha1);
46 hasher.update(data);
47 let oid = hasher
48 .try_finalize()
49 .map_err(|e| BabyError::Custom(format!("archive cache key hash failed: {}", e)))?;
50 Ok(oid.to_string())
51}
52
53#[derive(Serialize)]
54struct GetArchiveCacheKey<'a> {
55 oid: String,
56 format: &'a str,
57 prefix: Option<&'a str>,
58 path: Option<&'a str>,
59 exclude: Vec<&'a str>,
60}
61
62/// get_archive 缓存键:commit 解析为 oid(防分支移动命中旧包),与其余参数规范化后哈希。
63/// exclude 仅参与求键时排序,命令参数保持原序。
64pub async fn get_archive_cache_key(
65 gitbaby: &crate::GitBaby,
66 req: &GetArchiveRequest,
67) -> Result<String, BabyError> {
68 let oid = gitbaby.resolve_revision(&req.commit).await?;
69 let mut exclude: Vec<&str> = req.exclude.iter().map(String::as_str).collect();
70 exclude.sort_unstable();
71 let payload = GetArchiveCacheKey {
72 oid: oid.to_string(),
73 format: &req.format.to_string(),
74 prefix: req.prefix.as_deref(),
75 path: req.path.as_deref(),
76 exclude,
77 };
78 let text = serde_json::to_vec(&payload)
79 .map_err(|e| BabyError::Custom(format!("archive cache key serialization failed: {}", e)))?;
80 sha1_hex(&text)
81}
82
83/// create_bundle 缓存键:refs 以原顺序拼接待哈希,不解析。
84pub fn bundle_cache_key(req: &BundleRequest) -> Result<String, BabyError> {
85 sha1_hex(req.refs.join("\n").as_bytes())
86}
87
88pub async fn open_cached_file(path: &Path) -> Result<Option<tokio::fs::File>, BabyError> {
89 match tokio::fs::File::open(path).await {
90 Ok(file) => Ok(Some(file)),
91 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
92 Err(e) => Err(BabyError::Io {
93 path: path.to_path_buf(),
94 source: e,
95 }),
96 }
97}
98
99type PersistFut =
100 Pin<Box<dyn std::future::Future<Output = (Option<tokio::fs::File>, io::Result<()>)> + Send>>;
101
102/// 流式透写读取器:每块数据在返回给调用方之前先落盘,
103/// 读尽后 flush 并以原子 rename 移到最终路径;任何写/rename 失败都在流上显式报错。
104pub struct PersistReader<R> {
105 inner: R,
106 file: Option<tokio::fs::File>,
107 final_path: PathBuf,
108 tmp_path: PathBuf,
109 pending: Vec<u8>,
110 write_fut: Option<PersistFut>,
111 scratch: Vec<u8>,
112 eof: bool,
113 finalized: bool,
114 error: Option<io::Error>,
115}
116
117impl<R> PersistReader<R> {
118 pub fn new(inner: R, file: tokio::fs::File, tmp_path: PathBuf, final_path: PathBuf) -> Self {
119 Self {
120 inner,
121 file: Some(file),
122 final_path,
123 tmp_path,
124 pending: Vec::new(),
125 write_fut: None,
126 scratch: vec![0u8; 16 * 1024],
127 eof: false,
128 finalized: false,
129 error: None,
130 }
131 }
132}
133
134impl<R: tokio::io::AsyncRead + Unpin + Send> tokio::io::AsyncRead for PersistReader<R> {
135 fn poll_read(
136 mut self: Pin<&mut Self>,
137 cx: &mut Context<'_>,
138 buf: &mut tokio::io::ReadBuf<'_>,
139 ) -> Poll<io::Result<()>> {
140 let this = self.as_mut().get_mut();
141 loop {
142 if let Some(e) = this.error.take() {
143 return Poll::Ready(Err(e));
144 }
145 if this.finalized {
146 return Poll::Ready(Ok(()));
147 }
148 if let Some(mut fut) = this.write_fut.take() {
149 match fut.as_mut().poll(cx) {
150 Poll::Pending => {
151 this.write_fut = Some(fut);
152 return Poll::Pending;
153 }
154 Poll::Ready((file, result)) => {
155 this.file = file;
156 if let Err(e) = result {
157 let _ = std::fs::remove_file(&this.tmp_path);
158 this.error = Some(e);
159 }
160 continue;
161 }
162 }
163 }
164 if !this.pending.is_empty() {
165 let n = buf.remaining().min(this.pending.len());
166 if n == 0 {
167 return Poll::Pending;
168 }
169 buf.put_slice(&this.pending[..n]);
170 if n == this.pending.len() {
171 this.pending.clear();
172 } else {
173 this.pending.drain(..n);
174 }
175 return Poll::Ready(Ok(()));
176 }
177 if this.eof {
178 if let Some(file) = this.file.take() {
179 let tmp_path = this.tmp_path.clone();
180 let final_path = this.final_path.clone();
181 this.write_fut = Some(Box::pin(async move {
182 let mut file = file;
183 if let Err(e) = file.flush().await {
184 let _ = std::fs::remove_file(&tmp_path);
185 return (None, Err(e));
186 }
187 drop(file);
188 if let Err(e) = tokio::fs::rename(&tmp_path, &final_path).await {
189 let _ = std::fs::remove_file(&tmp_path);
190 return (None, Err(e));
191 }
192 (None, Ok(()))
193 }));
194 continue;
195 }
196 this.finalized = true;
197 return Poll::Ready(Ok(()));
198 }
199 let mut scratch_buf = tokio::io::ReadBuf::new(&mut this.scratch);
200 match Pin::new(&mut this.inner).poll_read(cx, &mut scratch_buf) {
201 Poll::Pending => return Poll::Pending,
202 Poll::Ready(Err(e)) => {
203 let _ = std::fs::remove_file(&this.tmp_path);
204 return Poll::Ready(Err(e));
205 }
206 Poll::Ready(Ok(())) => {
207 let n = scratch_buf.filled().len();
208 if n == 0 {
209 this.eof = true;
210 continue;
211 }
212 this.pending = scratch_buf.filled().to_vec();
213 if let Some(file) = this.file.take() {
214 let chunk = this.pending.clone();
215 this.write_fut = Some(Box::pin(async move {
216 let mut file = file;
217 let result = file.write_all(&chunk).await;
218 (Some(file), result)
219 }));
220 }
221 continue;
222 }
223 }
224 }
225 }
226}
227
228pub fn build_create_archive_cmd(
229 dir: &Path,
230 env: Env,
231 req: &crate::archive::types::CreateArchiveRequest,
232) -> Result<Cmd, BabyError> {
233 crate::share::build_archive_cmd(
234 "gitbaby::archive",
235 dir,
236 env,
237 req.format.as_arg(),
238 req.prefix.as_deref(),
239 &req.commit,
240 &req.paths,
241 )
242}
243
244pub fn build_get_archive_cmd(
245 dir: &Path,
246 env: Env,
247 req: &crate::archive::types::GetArchiveRequest,
248) -> Result<Cmd, BabyError> {
249 crate::share::validate_revision(&req.commit)?;
250 if let Some(p) = &req.prefix {
251 crate::share::reject_starts_with_dash(p, "archive prefix")?;
252 if p.contains('\0') || p.contains('\n') || p.contains('\r') {
253 return Err(BabyError::InvalidGitArg(format!(
254 "archive prefix contains control character: `{}`",
255 p
256 )));
257 }
258 }
259 for exc in &req.exclude {
260 crate::share::reject_starts_with_dash(exc, "archive exclude")?;
261 }
262 let mut args = vec!["archive".to_string()];
263 match req.format {
264 crate::archive::types::ArchiveFormat::Tar => args.push("--format=tar".to_string()),
265 crate::archive::types::ArchiveFormat::TarGz => args.push("--format=tar.gz".to_string()),
266 crate::archive::types::ArchiveFormat::TarBz2 => args.push("--format=tar.bz2".to_string()),
267 crate::archive::types::ArchiveFormat::Zip => args.push("--format=zip".to_string()),
268 }
269 if let Some(p) = &req.prefix {
270 args.push(format!("--prefix={}", p));
271 }
272 let rev = match &req.path {
273 Some(p) => {
274 crate::share::path::validate_local_no_escape(std::path::Path::new(p))?;
275 format!("{}:{}", req.commit, p)
276 }
277 None => req.commit.clone(),
278 };
279 args.push("--end-of-options".to_string());
280 args.push(rev);
281 if !req.exclude.is_empty() {
282 args.push("--".to_string());
283 for exc in &req.exclude {
284 args.push(format!(":(exclude){}", exc));
285 }
286 }
287 Ok(crate::share::git_cmd(
288 "gitbaby::archive",
289 dir,
290 env,
291 None,
292 args,
293 ))
294}
295
296pub fn build_bundle_cmd(
297 dir: &Path,
298 env: Env,
299 req: &crate::archive::types::BundleRequest,
300) -> Result<Cmd, BabyError> {
301 for r in &req.refs {
302 crate::share::reject_starts_with_dash(r, "bundle ref")?;
303 }
304 let mut args = vec![
305 "bundle".to_string(),
306 "create".to_string(),
307 "-".to_string(),
308 "--end-of-options".to_string(),
309 ];
310 for r in &req.refs {
311 args.push(r.clone());
312 }
313 Ok(crate::share::git_cmd(
314 "gitbaby::archive",
315 dir,
316 env,
317 None,
318 args,
319 ))
320}
321
322pub fn classify_stderr(stderr: &str) -> BabyError {
323 BabyError::ArchiveFailed(stderr.trim().to_string())
324}
Added lib/gitbaby/src/archive/mod.rs lib/gitbaby/src/archive/mod.rs +7 −0 View lib/gitbaby/src/archive/mod.rs
@@ -0,0 +1,7 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{
6 ArchiveFormat, BundleRequest, ChildArchiveReader, CreateArchiveRequest, GetArchiveRequest,
7};
Added lib/gitbaby/src/archive/types.rs lib/gitbaby/src/archive/types.rs +132 −0 View lib/gitbaby/src/archive/types.rs
@@ -0,0 +1,132 @@
1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::command::env::Env;
6use crate::error::BabyError;
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8pub enum ArchiveFormat {
9 Tar,
10 TarGz,
11 TarBz2,
12 Zip,
13}
14
15impl ArchiveFormat {
16 pub fn as_arg(&self) -> crate::share::ArchiveFormatArg {
17 match self {
18 Self::Tar => crate::share::ArchiveFormatArg::Tar,
19 Self::TarGz => crate::share::ArchiveFormatArg::TarGz,
20 Self::TarBz2 => crate::share::ArchiveFormatArg::TarBz2,
21 Self::Zip => crate::share::ArchiveFormatArg::Zip,
22 }
23 }
24}
25
26impl fmt::Display for ArchiveFormat {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 match self {
29 Self::Tar => write!(f, "tar"),
30 Self::TarGz => write!(f, "tar.gz"),
31 Self::TarBz2 => write!(f, "tar.bz2"),
32 Self::Zip => write!(f, "zip"),
33 }
34 }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct CreateArchiveRequest {
39 pub commit: String,
40 pub format: ArchiveFormat,
41 pub prefix: Option<String>,
42 pub paths: Vec<String>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct GetArchiveRequest {
47 pub commit: String,
48 pub format: ArchiveFormat,
49 pub prefix: Option<String>,
50 pub path: Option<String>,
51 pub exclude: Vec<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct BundleRequest {
56 pub refs: Vec<String>,
57}
58
59#[derive(Debug)]
60pub struct ChildArchiveReader<R> {
61 reader: R,
62 child: Option<tokio::process::Child>,
63 finished: bool,
64 finished_after_eof: bool,
65}
66
67impl<R: tokio::io::AsyncRead + Unpin + Send> ChildArchiveReader<R> {
68 pub fn new(reader: R, child: Option<tokio::process::Child>) -> Self {
69 Self {
70 reader,
71 child,
72 finished: false,
73 finished_after_eof: false,
74 }
75 }
76
77 pub fn into_inner(self) -> (R, Option<tokio::process::Child>) {
78 (self.reader, self.child)
79 }
80
81 pub fn finished(&self) -> bool {
82 self.finished
83 }
84
85 pub async fn next_chunk(&mut self, buf: &mut [u8]) -> Result<usize, BabyError> {
86 use tokio::io::AsyncReadExt;
87 if self.finished {
88 return Ok(0);
89 }
90 let n = match self.reader.read(buf).await {
91 Ok(n) => n,
92 Err(e) => return Err(e.into()),
93 };
94 if n == 0 {
95 self.finished = true;
96 }
97 Ok(n)
98 }
99
100 pub async fn read_to_end(&mut self) -> Result<Vec<u8>, BabyError> {
101 use tokio::io::AsyncReadExt;
102 if self.finished_after_eof {
103 return Ok(Vec::new());
104 }
105 let mut buf = Vec::new();
106 match self.reader.read_to_end(&mut buf).await {
107 Ok(_) => {
108 self.finished = true;
109 self.finished_after_eof = true;
110 Ok(buf)
111 }
112 Err(e) => Err(e.into()),
113 }
114 }
115
116 pub async fn close(mut self) -> Result<(), BabyError> {
117 use tokio::io::AsyncReadExt;
118 let mut scratch = [0u8; 4096];
119 loop {
120 match self.reader.read(&mut scratch).await {
121 Ok(0) => break,
122 Ok(_) => continue,
123 Err(e) => return Err(e.into()),
124 }
125 }
126 if let Some(mut child) = self.child.take() {
127 let _ = child.wait().await;
128 }
129 let _ = std::marker::PhantomData::<Env>;
130 Ok(())
131 }
132}
Added lib/gitbaby/src/archive/usecase.rs lib/gitbaby/src/archive/usecase.rs +141 −0 View lib/gitbaby/src/archive/usecase.rs
@@ -0,0 +1,141 @@
1use tokio::io::{AsyncRead, BufReader};
2
3use crate::GitBaby;
4use crate::archive::helper;
5use crate::archive::types::{
6 BundleRequest, ChildArchiveReader, CreateArchiveRequest, GetArchiveRequest,
7};
8use crate::command::env::Env;
9use crate::error::BabyError;
10
11async fn build_env(gitbaby: &GitBaby) -> Result<(std::path::PathBuf, Env), BabyError> {
12 let dir = gitbaby
13 .facade
14 .git_repo_dir()
15 .await
16 .map_err(crate::share::facade_error)?;
17 let alternates = gitbaby
18 .facade
19 .git_alternate_object_directories()
20 .await
21 .map_err(crate::share::facade_error)?;
22 Ok((dir, crate::share::build_env_safe(&alternates)))
23}
24
25impl GitBaby {
26 pub async fn create_archive(&self, req: CreateArchiveRequest) -> Result<(), BabyError> {
27 let (dir, env) = build_env(self).await?;
28 let mut cmd = helper::build_create_archive_cmd(&dir, env, &req)?;
29 let output = cmd.run_soft().await.map_err(BabyError::from)?;
30 if output.status.success() {
31 Ok(())
32 } else {
33 Err(helper::classify_stderr(&String::from_utf8_lossy(
34 &output.stderr,
35 )))
36 }
37 }
38
39 pub async fn get_archive(
40 &self,
41 req: GetArchiveRequest,
42 ) -> Result<ChildArchiveReader<BufReader<Box<dyn AsyncRead + Unpin + Send>>>, BabyError> {
43 let (dir, env) = build_env(self).await?;
44 let key = helper::get_archive_cache_key(self, &req).await?;
45 let final_path = helper::archive_file_path(&dir, &key, helper::format_ext(req.format));
46 if let Some(file) = helper::open_cached_file(&final_path).await? {
47 return Ok(ChildArchiveReader::new(
48 BufReader::new(Box::new(file) as Box<dyn AsyncRead + Unpin + Send>),
49 None,
50 ));
51 }
52 let tmp_path = helper::tmp_archive_path(&final_path);
53 tokio::fs::create_dir_all(helper::archive_store_dir(&dir))
54 .await
55 .map_err(|source| BabyError::Io {
56 path: helper::archive_store_dir(&dir),
57 source,
58 })?;
59 let file = tokio::fs::File::create(&tmp_path)
60 .await
61 .map_err(|source| BabyError::Io {
62 path: tmp_path.clone(),
63 source,
64 })?;
65 let mut cmd = helper::build_get_archive_cmd(&dir, env, &req)?;
66 cmd.spawn().await.map_err(BabyError::from)?;
67 let stdout: Box<dyn AsyncRead + Unpin + Send + Sync> = std::mem::replace(
68 &mut cmd.stdout,
69 Box::new(tokio::io::empty()) as Box<dyn AsyncRead + Unpin + Send + Sync>,
70 );
71 // Hand the child to the reader so `close()` can reap it, and drain
72 // stderr in the background so a chatty child cannot fill the pipe
73 // buffer and deadlock.
74 let child = cmd.cmd.take();
75 drain_stderr_background(&mut cmd);
76 let reader = helper::PersistReader::new(stdout, file, tmp_path, final_path);
77 Ok(ChildArchiveReader::new(
78 BufReader::new(Box::new(reader) as Box<dyn AsyncRead + Unpin + Send>),
79 child,
80 ))
81 }
82
83 pub async fn create_bundle(
84 &self,
85 req: BundleRequest,
86 ) -> Result<ChildArchiveReader<BufReader<Box<dyn AsyncRead + Unpin + Send>>>, BabyError> {
87 if req.refs.is_empty() {
88 return Err(BabyError::Custom(
89 "bundle requires at least one ref".to_string(),
90 ));
91 }
92 let (dir, env) = build_env(self).await?;
93 let key = helper::bundle_cache_key(&req)?;
94 let final_path = helper::archive_file_path(&dir, &key, "bundle");
95 if let Some(file) = helper::open_cached_file(&final_path).await? {
96 return Ok(ChildArchiveReader::new(
97 BufReader::new(Box::new(file) as Box<dyn AsyncRead + Unpin + Send>),
98 None,
99 ));
100 }
101 let tmp_path = helper::tmp_archive_path(&final_path);
102 tokio::fs::create_dir_all(helper::archive_store_dir(&dir))
103 .await
104 .map_err(|source| BabyError::Io {
105 path: helper::archive_store_dir(&dir),
106 source,
107 })?;
108 let file = tokio::fs::File::create(&tmp_path)
109 .await
110 .map_err(|source| BabyError::Io {
111 path: tmp_path.clone(),
112 source,
113 })?;
114 let mut cmd = helper::build_bundle_cmd(&dir, env, &req)?;
115 cmd.spawn().await.map_err(BabyError::from)?;
116 let stdout: Box<dyn AsyncRead + Unpin + Send + Sync> = std::mem::replace(
117 &mut cmd.stdout,
118 Box::new(tokio::io::empty()) as Box<dyn AsyncRead + Unpin + Send + Sync>,
119 );
120 let child = cmd.cmd.take();
121 drain_stderr_background(&mut cmd);
122 let reader = helper::PersistReader::new(stdout, file, tmp_path, final_path);
123 Ok(ChildArchiveReader::new(
124 BufReader::new(Box::new(reader) as Box<dyn AsyncRead + Unpin + Send>),
125 child,
126 ))
127 }
128}
129
130fn drain_stderr_background(cmd: &mut crate::command::cmd::Cmd) {
131 let stderr: Box<dyn AsyncRead + Unpin + Send + Sync> = std::mem::replace(
132 &mut cmd.stderr,
133 Box::new(tokio::io::empty()) as Box<dyn AsyncRead + Unpin + Send + Sync>,
134 );
135 tokio::spawn(async move {
136 use tokio::io::AsyncReadExt;
137 let mut stderr = stderr;
138 let mut buf = Vec::new();
139 let _ = stderr.read_to_end(&mut buf).await;
140 });
141}
Added lib/gitbaby/src/blame/helper.rs lib/gitbaby/src/blame/helper.rs +137 −0 View lib/gitbaby/src/blame/helper.rs
@@ -0,0 +1,137 @@
1use std::path::{Path, PathBuf};
2
3use crate::blame::types::BlameOptions;
4use crate::command::cmd::Cmd;
5use crate::command::env::Env;
6use crate::command::error::CmdError;
7use crate::error::BabyError;
8use crate::share;
9
10pub fn validate_options(opts: &BlameOptions) -> Result<(), BabyError> {
11 share::validate_revision(&opts.revision)?;
12 share::validate_local_no_escape(&opts.path)?;
13 if let Some(r) = opts.range
14 && (r.start == 0 || r.end == 0 || r.start > r.end)
15 {
16 return Err(BabyError::InvalidRange);
17 }
18 Ok(())
19}
20
21pub fn build_env(alternates: &[PathBuf]) -> Env {
22 share::build_env_safe(alternates)
23}
24
25pub fn build_cmd(opts: &BlameOptions, dir: &Path, env: Env, timeout: Option<u64>) -> Cmd {
26 let mut args: Vec<String> = Vec::with_capacity(8);
27 args.push("blame".to_string());
28 args.push("--porcelain".to_string());
29 if opts.bypass_ignore {
30 // `/dev/null` does not exist on Windows; use the platform's null device.
31 let empty_file = if cfg!(windows) { "NUL" } else { "/dev/null" };
32 args.push(format!("--ignore-revs-file={}", empty_file));
33 } else if let Some(ref f) = opts.ignore_revs_file {
34 args.push(format!("--ignore-revs-file={}", f.display()));
35 }
36 args.push(opts.revision.clone());
37 if let Some(r) = opts.range {
38 args.push("-L".to_string());
39 args.push(format!("{},{}", r.start, r.end));
40 }
41 args.push("--".to_string());
42 args.push(opts.path.to_string_lossy().into_owned());
43 share::git_cmd("gitbaby::blame", dir, env, timeout, args)
44}
45
46pub fn classify_stderr(stderr: &str, opts: &BlameOptions) -> BabyError {
47 let trimmed = stderr.trim();
48 if let Some(rest) = trimmed.strip_prefix("fatal: no such path ") {
49 return BabyError::PathNotFound {
50 revision: opts.revision.clone(),
51 path: PathBuf::from(rest.trim().trim_matches('"').trim_matches('\'')),
52 };
53 }
54 if let Some(rest) = trimmed.strip_prefix("fatal: invalid object name: ") {
55 if opts.ignore_revs_file.is_some() {
56 return BabyError::IgnoreRevsNotBlob {
57 revision: opts.revision.clone(),
58 };
59 }
60 return BabyError::Parse {
61 line_number: 0,
62 message: format!("git invalid object: {}", rest.trim()),
63 };
64 }
65 if let Some(rest) = trimmed.strip_prefix("fatal: file ")
66 && let Some((path_part, lines_part)) = rest.split_once(" has only ")
67 && let Some(lines_str) = lines_part
68 .strip_suffix(" lines")
69 .or_else(|| lines_part.strip_suffix(" line"))
70 && let Ok(n) = lines_str.trim().parse::<u64>()
71 {
72 let path = path_part
73 .trim()
74 .trim_end_matches(',')
75 .trim_matches('"')
76 .trim_matches('\'')
77 .to_string();
78 return BabyError::OutOfRange {
79 actual_lines: n,
80 revision: opts.revision.clone(),
81 path: PathBuf::from(path),
82 };
83 }
84 BabyError::Spawn {
85 prog: "git".to_string(),
86 source: std::io::Error::other("git blame non-zero exit"),
87 stderr: trimmed.to_string(),
88 }
89}
90
91pub fn classify_cmd_error(err: CmdError, opts: &BlameOptions) -> BabyError {
92 match err {
93 CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, opts),
94 CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
95 other => BabyError::from(other),
96 }
97}
98
99pub fn facade_error<E: std::error::Error + Send + Sync + 'static>(e: E) -> BabyError {
100 share::facade_error(e)
101}
102
103pub fn parse_header_sha(line: &str, object_id_len: usize) -> Option<(gix::ObjectId, &str)> {
104 if line.len() < object_id_len {
105 return None;
106 }
107 let (head, rest) = line.split_at(object_id_len);
108 if !rest.starts_with(' ') {
109 return None;
110 }
111 let sha = gix::ObjectId::from_hex(head.as_bytes()).ok()?;
112 Some((sha, &rest[1..]))
113}
114
115pub struct HeaderParts {
116 pub orig_start: u32,
117 pub final_start: u32,
118 pub final_count: u32,
119}
120
121pub fn parse_header_trailing(rest: &str) -> Option<HeaderParts> {
122 let mut it = rest.split(' ');
123 let a = it.next()?.parse::<u32>().ok()?;
124 let b = it.next()?.parse::<u32>().ok()?;
125 let c = match it.next() {
126 Some(v) => v.parse::<u32>().ok()?,
127 None => 1,
128 };
129 if it.next().is_some() {
130 return None;
131 }
132 Some(HeaderParts {
133 orig_start: a,
134 final_start: b,
135 final_count: c,
136 })
137}
Added lib/gitbaby/src/blame/mod.rs lib/gitbaby/src/blame/mod.rs +6 −0 View lib/gitbaby/src/blame/mod.rs
@@ -0,0 +1,6 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{BlameHunk, BlameOptions, Range};
6pub use usecase::ChildBlameReader;
Added lib/gitbaby/src/blame/types.rs lib/gitbaby/src/blame/types.rs +35 −0 View lib/gitbaby/src/blame/types.rs
@@ -0,0 +1,35 @@
1use std::fmt;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub struct BlameHunk {
7 pub sha: gix::ObjectId,
8 pub previous_sha: Option<gix::ObjectId>,
9 pub previous_path: Option<PathBuf>,
10 pub lines: Vec<String>,
11 pub final_start_line: u32,
12 pub final_line_count: u32,
13 pub orig_start_line: u32,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub struct Range {
18 pub start: u32,
19 pub end: u32,
20}
21
22impl fmt::Display for Range {
23 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24 write!(f, "{},{}", self.start, self.end)
25 }
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct BlameOptions {
30 pub revision: String,
31 pub path: PathBuf,
32 pub range: Option<Range>,
33 pub ignore_revs_file: Option<PathBuf>,
34 pub bypass_ignore: bool,
35}
Added lib/gitbaby/src/blame/usecase.rs lib/gitbaby/src/blame/usecase.rs +293 −0 View lib/gitbaby/src/blame/usecase.rs
@@ -0,0 +1,293 @@
1use std::path::Path;
2
3use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, BufReader};
4use tokio::process::Child;
5
6use crate::GitBaby;
7use crate::blame::helper;
8use crate::blame::types::{BlameHunk, BlameOptions, Range};
9use crate::command::cmd::Cmd;
10use crate::error::BabyError;
11
12pub struct ChildBlameReader<R: AsyncBufRead + Unpin + Send> {
13 reader: R,
14 object_id_len: usize,
15 pending: Option<BlameHunk>,
16 child: Option<Child>,
17 finished: bool,
18 line_no: u64,
19 stderr_tail: Option<String>,
20}
21
22impl<R: AsyncBufRead + Unpin + Send> ChildBlameReader<R> {
23 pub fn new(reader: R, object_id_len: usize, child: Option<Child>) -> Self {
24 Self {
25 reader,
26 object_id_len,
27 pending: None,
28 child,
29 finished: false,
30 line_no: 0,
31 stderr_tail: None,
32 }
33 }
34
35 pub fn into_inner(self) -> (R, Option<Child>) {
36 (self.reader, self.child)
37 }
38
39 pub fn finished(&self) -> bool {
40 self.finished
41 }
42
43 pub fn line_no(&self) -> u64 {
44 self.line_no
45 }
46
47 pub fn set_stderr_tail(&mut self, stderr: String) {
48 self.stderr_tail = Some(stderr);
49 }
50
51 pub async fn next_hunk(&mut self) -> Result<Option<BlameHunk>, BabyError> {
52 if self.finished {
53 return Ok(None);
54 }
55 let mut buf = String::new();
56 loop {
57 buf.clear();
58 let n = self
59 .reader
60 .read_line(&mut buf)
61 .await
62 .map_err(|source| BabyError::Io {
63 path: std::path::PathBuf::from("<git-blame stdout>"),
64 source,
65 })?;
66 if n == 0 {
67 self.finished = true;
68 return Ok(self.pending.take());
69 }
70 self.line_no += 1;
71 let trimmed = buf.trim_end_matches(['\n', '\r']);
72
73 if let Some((sha, rest)) = helper::parse_header_sha(trimmed, self.object_id_len) {
74 let header =
75 helper::parse_header_trailing(rest).ok_or_else(|| BabyError::Parse {
76 line_number: self.line_no,
77 message: format!("malformed header: {}", trimmed),
78 })?;
79 if let Some(prev) = self.pending.take() {
80 if prev.sha == sha {
81 let mut merged = prev;
82 merged.final_start_line = header.final_start;
83 merged.final_line_count = header.final_count;
84 merged.orig_start_line = header.orig_start;
85 self.pending = Some(merged);
86 continue;
87 }
88 self.pending = Some(BlameHunk {
89 sha,
90 previous_sha: None,
91 previous_path: None,
92 lines: Vec::new(),
93 final_start_line: header.final_start,
94 final_line_count: header.final_count,
95 orig_start_line: header.orig_start,
96 });
97 return Ok(Some(prev));
98 }
99 self.pending = Some(BlameHunk {
100 sha,
101 previous_sha: None,
102 previous_path: None,
103 lines: Vec::new(),
104 final_start_line: header.final_start,
105 final_line_count: header.final_count,
106 orig_start_line: header.orig_start,
107 });
108 continue;
109 }
110
111 if let Some(content) = buf.strip_prefix('\t') {
112 let content = content.trim_end_matches(['\n', '\r']);
113 match self.pending.as_mut() {
114 Some(h) => h.lines.push(content.to_string()),
115 None => {
116 return Err(BabyError::Parse {
117 line_number: self.line_no,
118 message: "content line outside of any hunk".into(),
119 });
120 }
121 }
122 continue;
123 }
124
125 if let Some(rest) = buf.strip_prefix("previous ") {
126 let rest = rest.trim_end_matches(['\n', '\r']);
127 let (sha_hex, path) = rest.split_once(' ').ok_or_else(|| BabyError::Parse {
128 line_number: self.line_no,
129 message: format!("malformed previous directive: {}", rest),
130 })?;
131 let prev_sha =
132 gix::ObjectId::from_hex(sha_hex.as_bytes()).map_err(|_| BabyError::Parse {
133 line_number: self.line_no,
134 message: format!("invalid previous sha: {}", sha_hex),
135 })?;
136 match self.pending.as_mut() {
137 Some(h) => {
138 h.previous_sha = Some(prev_sha);
139 h.previous_path = Some(std::path::PathBuf::from(path));
140 }
141 None => {
142 return Err(BabyError::Parse {
143 line_number: self.line_no,
144 message: "previous directive outside of any hunk".into(),
145 });
146 }
147 }
148 continue;
149 }
150
151 if trimmed == "boundary" {
152 continue;
153 }
154 }
155 }
156
157 pub async fn close(mut self) -> Result<(), BabyError> {
158 let Some(mut child) = self.child.take() else {
159 return Ok(());
160 };
161 let status = child.wait().await.map_err(|source| BabyError::Io {
162 path: std::path::PathBuf::from("<git-blame child>"),
163 source,
164 })?;
165 if !status.success() {
166 return Err(BabyError::Spawn {
167 prog: "git".to_string(),
168 source: std::io::Error::other(format!("exit status {:?}", status)),
169 stderr: self.stderr_tail.unwrap_or_default(),
170 });
171 }
172 Ok(())
173 }
174}
175
176impl GitBaby {
177 pub async fn stream_blame(
178 &self,
179 opts: BlameOptions,
180 ) -> Result<ChildBlameReader<BufReader<Box<dyn AsyncRead + Unpin + Send + Sync>>>, BabyError>
181 {
182 let (mut cmd, hex_len) = self.spawn_git_blame(&opts).await?;
183 cmd.spawn()
184 .await
185 .map_err(|e| helper::classify_cmd_error(e, &opts))?;
186 let mut child = cmd
187 .cmd
188 .take()
189 .ok_or_else(|| BabyError::Custom("git blame child not spawned".into()))?;
190 let stdout = std::mem::replace(&mut cmd.stdout, Box::new(tokio::io::empty()));
191 let _ = child.stdout.take();
192 let _ = child.stdin.take();
193 let _ = child.stderr.take();
194 Ok(ChildBlameReader::new(
195 BufReader::new(stdout),
196 hex_len,
197 Some(child),
198 ))
199 }
200
201 pub async fn blame_all(&self, opts: BlameOptions) -> Result<Vec<BlameHunk>, BabyError> {
202 let (mut cmd, hex_len) = self.spawn_git_blame(&opts).await?;
203 cmd.spawn()
204 .await
205 .map_err(|e| helper::classify_cmd_error(e, &opts))?;
206 let mut child = cmd
207 .cmd
208 .take()
209 .ok_or_else(|| BabyError::Custom("git blame child not spawned".into()))?;
210 let stdout = std::mem::replace(&mut cmd.stdout, Box::new(tokio::io::empty()));
211 let _ = child.stdout.take();
212 let _ = child.stdin.take();
213 let _ = child.stderr.take();
214 let mut reader = ChildBlameReader::new(BufReader::new(stdout), hex_len, Some(child));
215 let mut out = Vec::new();
216 while let Some(h) = reader.next_hunk().await? {
217 out.push(h);
218 }
219 reader.close().await?;
220 Ok(out)
221 }
222
223 pub async fn blame_line(
224 &self,
225 revision: &str,
226 path: &Path,
227 line: u32,
228 ) -> Result<String, BabyError> {
229 let opts = BlameOptions {
230 revision: revision.to_string(),
231 path: path.to_path_buf(),
232 range: Some(Range {
233 start: line,
234 end: line,
235 }),
236 ignore_revs_file: None,
237 bypass_ignore: false,
238 };
239 let (mut cmd, hex_len) = self.spawn_git_blame(&opts).await?;
240 let output = cmd
241 .run()
242 .await
243 .map_err(|e| helper::classify_cmd_error(e, &opts))?;
244 if !output.status.success() {
245 return Err(helper::classify_stderr(
246 &String::from_utf8_lossy(&output.stderr),
247 &opts,
248 ));
249 }
250 let stdout = String::from_utf8_lossy(&output.stdout);
251 for line_str in stdout.lines() {
252 let trimmed = line_str.trim_end();
253 if let Some(content) = trimmed.strip_prefix('\t') {
254 return Ok(content.to_string());
255 }
256 if let Some((_sha, rest)) = helper::parse_header_sha(trimmed, hex_len)
257 && helper::parse_header_trailing(rest).is_some()
258 {
259 continue;
260 }
261 if trimmed.starts_with("previous ") {
262 continue;
263 }
264 if trimmed == "boundary" {
265 continue;
266 }
267 }
268 Err(BabyError::OutOfRange {
269 actual_lines: u64::from(line).saturating_sub(1),
270 revision: revision.to_string(),
271 path: path.to_path_buf(),
272 })
273 }
274
275 async fn spawn_git_blame(&self, opts: &BlameOptions) -> Result<(Cmd, usize), BabyError> {
276 helper::validate_options(opts)?;
277 let dir = self
278 .facade
279 .git_repo_dir()
280 .await
281 .map_err(helper::facade_error)?;
282 let alternates = self
283 .facade
284 .git_alternate_object_directories()
285 .await
286 .map_err(helper::facade_error)?;
287 let gix_repo = self.facade.gix_repo().await.map_err(helper::facade_error)?;
288 let env = helper::build_env(&alternates);
289 let cmd = helper::build_cmd(opts, &dir, env, None);
290 let hex_len = gix_repo.object_hash().len_in_hex();
291 Ok((cmd, hex_len))
292 }
293}
Added lib/gitbaby/src/blob/helper.rs lib/gitbaby/src/blob/helper.rs +228 −0 View lib/gitbaby/src/blob/helper.rs
@@ -0,0 +1,228 @@
1use std::path::Path;
2
3use gix::ObjectId;
4
5use crate::blob::types::{
6 BlobInfo, GetLFSPointersOptions, LFSPointer, ListAllBlobsOptions, ListAllLFSPointersOptions,
7 ListBlobsOptions, ListLFSPointersOptions, WriteBlobOptions,
8};
9use crate::command::cmd::Cmd;
10use crate::command::env::Env;
11use crate::error::BabyError;
12use crate::share;
13
14pub const CALLER: &str = "gitbaby::blob";
15
16pub fn build_get_blob_cmd(dir: &Path, env: Env, oid: &ObjectId) -> Cmd {
17 share::git_cat_file_cmd(dir, env, oid, share::CatFileSubcommand::Blob)
18}
19
20pub fn build_get_blob_size_cmd(dir: &Path, env: Env, oid: &ObjectId) -> Cmd {
21 let args = vec!["cat-file".to_string(), "-s".to_string(), oid.to_string()];
22 share::git_cmd(CALLER, dir, env, None, args)
23}
24
25pub fn build_blob_exists_cmd(dir: &Path, env: Env, oid: &ObjectId) -> Cmd {
26 let args = vec!["cat-file".to_string(), "-e".to_string(), oid.to_string()];
27 share::git_cmd(CALLER, dir, env, None, args)
28}
29
30pub fn build_stream_blob_cmd(dir: &Path, env: Env, oid: &ObjectId) -> Cmd {
31 share::git_cat_file_cmd(dir, env, oid, share::CatFileSubcommand::Blob)
32}
33
34pub fn build_list_blobs_cmd(dir: &Path, env: Env, opts: &ListBlobsOptions) -> Cmd {
35 let mut args = vec!["cat-file".to_string(), "--batch-all-objects".to_string()];
36 if let Some(b) = opts.bytes_limit {
37 if b > 0 {
38 args.push("--batch".to_string());
39 } else {
40 args.push("--batch-check".to_string());
41 }
42 } else {
43 args.push("--batch-check".to_string());
44 }
45 share::git_cmd(CALLER, dir, env, None, args)
46}
47
48pub fn build_list_all_blobs_cmd(dir: &Path, env: Env, opts: &ListAllBlobsOptions) -> Cmd {
49 let mut args = vec!["cat-file".to_string(), "--batch-all-objects".to_string()];
50 if let Some(b) = opts.bytes_limit {
51 if b > 0 {
52 args.push("--batch".to_string());
53 } else {
54 args.push("--batch-check".to_string());
55 }
56 } else {
57 args.push("--batch-check".to_string());
58 }
59 share::git_cmd(CALLER, dir, env, None, args)
60}
61
62pub fn build_list_lfs_pointers_cmd(dir: &Path, env: Env, opts: &ListLFSPointersOptions) -> Cmd {
63 let mut args = vec![
64 "rev-list".to_string(),
65 "--objects".to_string(),
66 "--unpacked".to_string(),
67 ];
68 if let Some(l) = opts.limit {
69 args.push("--max-count".to_string());
70 args.push(l.to_string());
71 }
72 for r in &opts.revisions {
73 args.push(r.clone());
74 }
75 share::git_cmd(CALLER, dir, env, None, args)
76}
77
78pub fn build_list_all_lfs_pointers_cmd(
79 dir: &Path,
80 env: Env,
81 _opts: &ListAllLFSPointersOptions,
82) -> Cmd {
83 let args = vec![
84 "cat-file".to_string(),
85 "--batch-all-objects".to_string(),
86 "--batch-check".to_string(),
87 ];
88 share::git_cmd(CALLER, dir, env, None, args)
89}
90
91pub fn build_get_lfs_pointers_cmd(dir: &Path, env: Env, _opts: &GetLFSPointersOptions) -> Cmd {
92 let args = vec![
93 "cat-file".to_string(),
94 "--batch-all-objects".to_string(),
95 "--batch-check".to_string(),
96 ];
97 share::git_cmd(CALLER, dir, env, None, args)
98}
99
100pub fn build_write_blob_cmd(dir: &Path, env: Env, opts: &WriteBlobOptions) -> Cmd {
101 share::git_hash_object_cmd(dir, env, opts.path.as_deref())
102}
103
104pub fn classify_blob_not_found(stderr: &str) -> BabyError {
105 BabyError::BlobNotFound {
106 oid: stderr.trim().to_string(),
107 }
108}
109
110pub fn build_blob_env(alternates: &[std::path::PathBuf]) -> Env {
111 crate::share::build_env_safe(alternates)
112}
113
114pub fn classify_stderr(stderr: &str) -> BabyError {
115 let s = stderr.trim();
116 if s.contains("Not a valid object") || s.contains("does not exist") {
117 BabyError::BlobNotFound { oid: s.to_string() }
118 } else {
119 BabyError::Spawn {
120 prog: "git".to_string(),
121 source: std::io::Error::other("git command failed"),
122 stderr: s.to_string(),
123 }
124 }
125}
126
127pub fn classify_cmd_error(err: crate::command::error::CmdError) -> BabyError {
128 match err {
129 crate::command::error::CmdError::NonZeroExit { stderr, .. } => {
130 let s = String::from_utf8_lossy(stderr.as_bytes()).into_owned();
131 classify_stderr(&s)
132 }
133 crate::command::error::CmdError::Spawn { prog, source } => {
134 share::classify_spawn_error(prog, source)
135 }
136 other => BabyError::from(other),
137 }
138}
139
140pub fn parse_cat_file_blob_output(
141 stdout: &[u8],
142 oid: &ObjectId,
143 size: i64,
144) -> Result<BlobInfo, BabyError> {
145 Ok(BlobInfo {
146 oid: *oid,
147 size,
148 data: Some(stdout.to_vec()),
149 })
150}
151
152pub fn parse_batch_check_line(line: &str) -> Result<Option<BlobInfo>, BabyError> {
153 let mut parts = line.split_whitespace();
154 let oid_str = parts
155 .next()
156 .ok_or_else(|| BabyError::Custom("batch-check line missing oid".to_string()))?;
157 let kind_str = parts
158 .next()
159 .ok_or_else(|| BabyError::Custom("batch-check line missing type".to_string()))?;
160 let size_str = parts
161 .next()
162 .ok_or_else(|| BabyError::Custom("batch-check line missing size".to_string()))?;
163 if kind_str != "blob" {
164 return Ok(None);
165 }
166 let oid = share::parse_object_id(oid_str)?;
167 let size: i64 = size_str
168 .parse()
169 .map_err(|_| BabyError::Custom(format!("batch-check size `{}`", size_str)))?;
170 Ok(Some(BlobInfo {
171 oid,
172 size,
173 data: None,
174 }))
175}
176
177pub fn parse_lfs_pointer(bytes: &[u8], oid: &ObjectId) -> Result<LFSPointer, BabyError> {
178 let s = std::str::from_utf8(bytes).map_err(|source| BabyError::LFSPointerInvalid {
179 oid: oid.to_string(),
180 reason: format!("invalid utf-8: {}", source),
181 })?;
182 let mut version_line: Option<&str> = None;
183 let mut oid_line: Option<&str> = None;
184 let mut size_line: Option<&str> = None;
185 for line in s.lines() {
186 let mut parts = line.splitn(2, ' ');
187 let key = parts.next().unwrap_or("");
188 let value = parts.next().unwrap_or("");
189 match key {
190 "version" => version_line = Some(value),
191 "oid" => oid_line = Some(value),
192 "size" => size_line = Some(value),
193 _ => {}
194 }
195 }
196 let version = version_line.ok_or_else(|| BabyError::LFSPointerInvalid {
197 oid: oid.to_string(),
198 reason: "missing version".to_string(),
199 })?;
200 if !version.contains("git-lfs") {
201 return Err(BabyError::LFSPointerInvalid {
202 oid: oid.to_string(),
203 reason: format!("unknown version `{}`", version),
204 });
205 }
206 let oid_hex = oid_line.ok_or_else(|| BabyError::LFSPointerInvalid {
207 oid: oid.to_string(),
208 reason: "missing oid".to_string(),
209 })?;
210 let size_str = size_line.ok_or_else(|| BabyError::LFSPointerInvalid {
211 oid: oid.to_string(),
212 reason: "missing size".to_string(),
213 })?;
214 let file_oid = share::parse_object_id(oid_hex)?;
215 let file_size: i64 = size_str
216 .parse()
217 .map_err(|source| BabyError::LFSPointerInvalid {
218 oid: oid.to_string(),
219 reason: format!("invalid size `{}`: {}", size_str, source),
220 })?;
221 Ok(LFSPointer {
222 oid: *oid,
223 size: bytes.len() as i64,
224 file_oid,
225 file_size,
226 content: bytes.to_vec(),
227 })
228}
Added lib/gitbaby/src/blob/mod.rs lib/gitbaby/src/blob/mod.rs +9 −0 View lib/gitbaby/src/blob/mod.rs
@@ -0,0 +1,9 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{
6 BlobContent, BlobInfo, GetBlobOptions, GetLFSPointersOptions, LFSPointer, ListAllBlobsOptions,
7 ListAllLFSPointersOptions, ListBlobsOptions, ListLFSPointersOptions, WriteBlobOptions,
8};
9pub use usecase::ChildBlobReader;
Added lib/gitbaby/src/blob/types.rs lib/gitbaby/src/blob/types.rs +64 −0 View lib/gitbaby/src/blob/types.rs
@@ -0,0 +1,64 @@
1use gix::ObjectId;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5pub struct BlobInfo {
6 pub oid: ObjectId,
7 pub size: i64,
8 pub data: Option<Vec<u8>>,
9}
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct BlobContent {
13 pub info: BlobInfo,
14 pub data: Vec<u8>,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct LFSPointer {
19 pub oid: ObjectId,
20 pub size: i64,
21 pub file_oid: ObjectId,
22 pub file_size: i64,
23 pub content: Vec<u8>,
24}
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct GetBlobOptions {
28 pub limit: Option<i64>,
29}
30
31#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32pub struct ListBlobsOptions {
33 pub revisions: Vec<String>,
34 pub limit: Option<u32>,
35 pub bytes_limit: Option<i64>,
36 pub with_paths: bool,
37}
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40pub struct ListAllBlobsOptions {
41 pub limit: Option<u32>,
42 pub bytes_limit: Option<i64>,
43}
44
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct ListLFSPointersOptions {
47 pub revisions: Vec<String>,
48 pub limit: Option<u32>,
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52pub struct ListAllLFSPointersOptions {
53 pub limit: Option<u32>,
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct GetLFSPointersOptions {
58 pub blob_ids: Vec<ObjectId>,
59}
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62pub struct WriteBlobOptions {
63 pub path: Option<String>,
64}
Added lib/gitbaby/src/blob/usecase.rs lib/gitbaby/src/blob/usecase.rs +368 −0 View lib/gitbaby/src/blob/usecase.rs
@@ -0,0 +1,368 @@
1use std::path::{Path, PathBuf};
2
3use tokio::io::{AsyncBufRead, AsyncReadExt, BufReader};
4use tokio::process::Child;
5
6use gix::ObjectId;
7
8use crate::GitBaby;
9use crate::blob::helper;
10use crate::blob::types::{
11 BlobContent, BlobInfo, GetBlobOptions, GetLFSPointersOptions, LFSPointer, ListAllBlobsOptions,
12 ListAllLFSPointersOptions, ListBlobsOptions, ListLFSPointersOptions, WriteBlobOptions,
13};
14use crate::error::BabyError;
15
16const NEGATIVE_TTL_SECS: u64 = 60;
17
18pub struct ChildBlobReader<R: AsyncBufRead + Unpin + Send> {
19 reader: R,
20 child: Option<Child>,
21 finished: bool,
22}
23
24impl<R: AsyncBufRead + Unpin + Send> ChildBlobReader<R> {
25 pub fn new(reader: R, child: Option<Child>) -> Self {
26 Self {
27 reader,
28 child,
29 finished: false,
30 }
31 }
32
33 pub fn into_inner(self) -> (R, Option<Child>) {
34 (self.reader, self.child)
35 }
36
37 pub fn finished(&self) -> bool {
38 self.finished
39 }
40
41 pub async fn next_chunk(&mut self, buf: &mut [u8]) -> Result<usize, BabyError> {
42 if self.finished {
43 return Ok(0);
44 }
45 let n = self
46 .reader
47 .read(buf)
48 .await
49 .map_err(|source| BabyError::Io {
50 path: PathBuf::new(),
51 source,
52 })?;
53 if n == 0 {
54 self.finished = true;
55 }
56 Ok(n)
57 }
58
59 pub async fn read_to_end(&mut self, cap: Option<usize>) -> Result<Vec<u8>, BabyError> {
60 let mut out = Vec::new();
61 let mut buf = vec![0u8; 32 * 1024];
62 loop {
63 let n = self.next_chunk(&mut buf).await?;
64 if n == 0 {
65 break;
66 }
67 out.extend_from_slice(&buf[..n]);
68 if let Some(c) = cap
69 && out.len() >= c
70 {
71 out.truncate(c);
72 break;
73 }
74 }
75 Ok(out)
76 }
77
78 pub async fn close(mut self) -> Result<(), BabyError> {
79 if let Some(mut child) = self.child.take() {
80 let _ = child.wait().await;
81 }
82 Ok(())
83 }
84}
85
86impl GitBaby {
87 pub async fn get_blob(
88 &self,
89 oid: ObjectId,
90 opts: GetBlobOptions,
91 ) -> Result<BlobContent, BabyError> {
92 if let Some(p) = crate::cache::try_global_cache() {
93 let key = crate::cache::object_key(crate::cache::NS_BLOB, &oid);
94 if let Ok(Some(data)) = p.store().get(&key).await {
95 let full_size = data.len() as i64;
96 let mut data = data;
97 if let Some(limit) = opts.limit
98 && limit >= 0
99 && (data.len() as i64) > limit
100 {
101 data.truncate(limit as usize);
102 }
103 let info = helper::parse_cat_file_blob_output(&data, &oid, full_size)?;
104 return Ok(BlobContent { info, data });
105 }
106 }
107 let mut cmd = self
108 .spawn_blob_env_cmd(|dir, env| helper::build_get_blob_cmd(dir, env, &oid))
109 .await?;
110 let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?;
111 if !output.status.success() {
112 return Err(helper::classify_stderr(&String::from_utf8_lossy(
113 &output.stderr,
114 )));
115 }
116 let full_size = output.stdout.len() as i64;
117 let mut data = output.stdout;
118 if let Some(limit) = opts.limit
119 && limit >= 0
120 && (data.len() as i64) > limit
121 {
122 data.truncate(limit as usize);
123 }
124 // Cache the full blob (content-addressed) so subsequent get_blob and
125 // stream_blob calls can hit; limit truncation is applied on read.
126 if let Some(p) = crate::cache::try_global_cache() {
127 let key = crate::cache::object_key(crate::cache::NS_BLOB, &oid);
128 p.store().insert(key, data.clone());
129 }
130 let info = helper::parse_cat_file_blob_output(&data, &oid, full_size)?;
131 Ok(BlobContent { info, data })
132 }
133
134 pub async fn get_blob_size(&self, oid: ObjectId) -> Result<i64, BabyError> {
135 let mut cmd = self
136 .spawn_blob_env_cmd(|dir, env| helper::build_get_blob_size_cmd(dir, env, &oid))
137 .await?;
138 let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?;
139 if !output.status.success() {
140 return Err(helper::classify_stderr(&String::from_utf8_lossy(
141 &output.stderr,
142 )));
143 }
144 let s = String::from_utf8_lossy(&output.stdout);
145 let line = s
146 .lines()
147 .next()
148 .ok_or_else(|| BabyError::Custom("empty cat-file --batch-check output".to_string()))?;
149 let size_str = line.trim();
150 size_str.parse().map_err(|source| {
151 BabyError::Custom(format!("cat-file size `{}`: {}", size_str, source))
152 })
153 }
154
155 pub async fn blob_exists(&self, oid: ObjectId) -> bool {
156 let repo_dir = match self.facade.git_repo_dir().await {
157 Ok(d) => d,
158 Err(_) => {
159 return self.blob_exists_uncached(oid).await;
160 }
161 };
162 let repo_dir = match std::fs::canonicalize(&repo_dir) {
163 Ok(d) => d,
164 Err(_) => {
165 return self.blob_exists_uncached(oid).await;
166 }
167 };
168 let key = crate::cache::blob_exists_key(&repo_dir, &oid);
169 if let Some(p) = crate::cache::try_global_cache() {
170 match p.store().get(&key).await {
171 Ok(Some(v)) if v.len() == 9 => {
172 let exists = v[0] != 0;
173 if exists {
174 return true;
175 }
176 let ts = u64::from_le_bytes(v[1..9].try_into().unwrap_or([0u8; 8]));
177 if now_unix_secs().saturating_sub(ts) < NEGATIVE_TTL_SECS {
178 return false;
179 }
180 p.store().remove(&key);
181 }
182 Ok(_) | Err(_) => {}
183 }
184 }
185 let ok = self.blob_exists_uncached(oid).await;
186 if let Some(p) = crate::cache::try_global_cache() {
187 let mut v = Vec::with_capacity(9);
188 v.push(u8::from(ok));
189 v.extend_from_slice(&now_unix_secs().to_le_bytes());
190 p.store().insert(key, v);
191 }
192 ok
193 }
194
195 async fn blob_exists_uncached(&self, oid: ObjectId) -> bool {
196 let mut cmd = match self
197 .spawn_blob_env_cmd(|dir, env| helper::build_blob_exists_cmd(dir, env, &oid))
198 .await
199 {
200 Ok(c) => c,
201 Err(_) => return false,
202 };
203 match cmd.run().await {
204 Ok(o) => o.status.success(),
205 Err(_) => false,
206 }
207 }
208
209 pub async fn stream_blob(
210 &self,
211 oid: ObjectId,
212 ) -> Result<
213 ChildBlobReader<BufReader<Box<dyn tokio::io::AsyncRead + Unpin + Send + Sync>>>,
214 BabyError,
215 > {
216 if let Some(p) = crate::cache::try_global_cache() {
217 let key = crate::cache::object_key(crate::cache::NS_BLOB, &oid);
218 if let Ok(Some(v)) = p.store().get(&key).await {
219 let boxed = Box::new(std::io::Cursor::new(v))
220 as Box<dyn tokio::io::AsyncRead + Unpin + Send + Sync>;
221 return Ok(ChildBlobReader::new(BufReader::new(boxed), None));
222 }
223 }
224 let mut cmd = self
225 .spawn_blob_env_cmd(|dir, env| helper::build_stream_blob_cmd(dir, env, &oid))
226 .await?;
227 cmd.spawn().await.map_err(helper::classify_cmd_error)?;
228 let mut child = cmd
229 .cmd
230 .take()
231 .ok_or_else(|| BabyError::Custom("git cat-file child not spawned".to_string()))?;
232 let _ = child.stdout.take();
233 let stdout = std::mem::replace(
234 &mut cmd.stdout,
235 Box::new(tokio::io::empty()) as Box<dyn tokio::io::AsyncRead + Unpin + Send + Sync>,
236 );
237 Ok(ChildBlobReader::new(BufReader::new(stdout), Some(child)))
238 }
239
240 pub async fn list_blobs(&self, opts: ListBlobsOptions) -> Result<Vec<BlobInfo>, BabyError> {
241 let mut cmd = self
242 .spawn_blob_env_cmd(|dir, env| helper::build_list_blobs_cmd(dir, env, &opts))
243 .await?;
244 let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?;
245 if !output.status.success() {
246 return Err(helper::classify_stderr(&String::from_utf8_lossy(
247 &output.stderr,
248 )));
249 }
250 let s = String::from_utf8_lossy(&output.stdout);
251 let mut out = Vec::new();
252 for line in s.lines() {
253 if line.trim().is_empty() {
254 continue;
255 }
256 if let Some(info) = helper::parse_batch_check_line(line)? {
257 out.push(info);
258 if let Some(limit) = opts.limit
259 && out.len() >= limit as usize
260 {
261 break;
262 }
263 }
264 }
265 Ok(out)
266 }
267
268 pub async fn list_all_blobs(
269 &self,
270 opts: ListAllBlobsOptions,
271 ) -> Result<Vec<BlobInfo>, BabyError> {
272 let mut cmd = self
273 .spawn_blob_env_cmd(|dir, env| helper::build_list_all_blobs_cmd(dir, env, &opts))
274 .await?;
275 let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?;
276 if !output.status.success() {
277 return Err(helper::classify_stderr(&String::from_utf8_lossy(
278 &output.stderr,
279 )));
280 }
281 let s = String::from_utf8_lossy(&output.stdout);
282 let mut out = Vec::new();
283 for line in s.lines() {
284 if line.trim().is_empty() {
285 continue;
286 }
287 if let Some(info) = helper::parse_batch_check_line(line)? {
288 out.push(info);
289 if let Some(limit) = opts.limit
290 && out.len() >= limit as usize
291 {
292 break;
293 }
294 }
295 }
296 Ok(out)
297 }
298
299 pub async fn list_lfs_pointers(
300 &self,
301 _opts: ListLFSPointersOptions,
302 ) -> Result<Vec<LFSPointer>, BabyError> {
303 Err(BabyError::Unimplemented("list_lfs_pointers"))
304 }
305
306 pub async fn list_all_lfs_pointers(
307 &self,
308 _opts: ListAllLFSPointersOptions,
309 ) -> Result<Vec<LFSPointer>, BabyError> {
310 Err(BabyError::Unimplemented("list_all_lfs_pointers"))
311 }
312
313 pub async fn get_lfs_pointers(
314 &self,
315 _opts: GetLFSPointersOptions,
316 ) -> Result<Vec<LFSPointer>, BabyError> {
317 Err(BabyError::Unimplemented("get_lfs_pointers"))
318 }
319
320 pub async fn write_blob(
321 &self,
322 content: Vec<u8>,
323 opts: WriteBlobOptions,
324 ) -> Result<ObjectId, BabyError> {
325 let mut cmd = self
326 .spawn_blob_env_cmd(|dir, env| helper::build_write_blob_cmd(dir, env, &opts))
327 .await?;
328 cmd.spawn().await.map_err(helper::classify_cmd_error)?;
329 cmd.feed(&content)
330 .await
331 .map_err(helper::classify_cmd_error)?;
332 let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?;
333 if !output.status.success() {
334 return Err(helper::classify_stderr(&String::from_utf8_lossy(
335 &output.stderr,
336 )));
337 }
338 let hex = String::from_utf8_lossy(&output.stdout).trim().to_string();
339 let oid = crate::share::parse_object_id(&hex)?;
340 let _ = crate::cache::invalidation::hook_blob_written(self, &oid).await;
341 Ok(oid)
342 }
343
344 async fn spawn_blob_env_cmd<F>(&self, builder: F) -> Result<crate::command::cmd::Cmd, BabyError>
345 where
346 F: FnOnce(&Path, crate::command::env::Env) -> crate::command::cmd::Cmd,
347 {
348 let dir = self
349 .facade
350 .git_repo_dir()
351 .await
352 .map_err(crate::share::facade_error)?;
353 let alternates = self
354 .facade
355 .git_alternate_object_directories()
356 .await
357 .map_err(crate::share::facade_error)?;
358 let env = helper::build_blob_env(&alternates);
359 Ok(builder(&dir, env))
360 }
361}
362
363fn now_unix_secs() -> u64 {
364 std::time::SystemTime::now()
365 .duration_since(std::time::UNIX_EPOCH)
366 .map(|d| d.as_secs())
367 .unwrap_or(0)
368}
Added lib/gitbaby/src/branch/helper.rs lib/gitbaby/src/branch/helper.rs +184 −0 View lib/gitbaby/src/branch/helper.rs
@@ -0,0 +1,184 @@
1use std::path::Path;
2
3use crate::command::cmd::Cmd;
4use crate::command::env::Env;
5use crate::error::BabyError;
6use crate::share;
7
8use super::types::ListBranchesOptions;
9
10pub const CALLER: &str = "gitbaby::branch";
11
12pub fn validate_list_branches_options(opts: &ListBranchesOptions) -> Result<(), BabyError> {
13 for p in &opts.patterns {
14 if !p.starts_with("refs/heads/") {
15 return Err(BabyError::InvalidBranchName(p.clone()));
16 }
17 share::validate_ref_pattern(p)?;
18 }
19 Ok(())
20}
21
22pub fn build_list_branches_cmd(dir: &Path, env: Env, opts: &ListBranchesOptions) -> Cmd {
23 let args = share::build_for_each_ref_args(&opts.patterns, None, None, None, None, &[], false);
24 share::git_cmd(CALLER, dir, env, None, args)
25}
26
27pub fn build_get_default_branch_cmd(dir: &Path, env: Env) -> Cmd {
28 share::git_cmd(
29 CALLER,
30 dir,
31 env,
32 None,
33 vec![
34 "symbolic-ref".to_string(),
35 "--short".to_string(),
36 "HEAD".to_string(),
37 ],
38 )
39}
40
41pub fn build_set_default_branch_cmd(dir: &Path, env: Env, name: &str) -> Cmd {
42 share::git_cmd(
43 CALLER,
44 dir,
45 env,
46 None,
47 vec![
48 "symbolic-ref".to_string(),
49 "HEAD".to_string(),
50 format!("refs/heads/{}", name),
51 ],
52 )
53}
54
55pub fn build_branch_exists_cmd(dir: &Path, env: Env, name: &str) -> Cmd {
56 share::git_cmd(
57 CALLER,
58 dir,
59 env,
60 None,
61 vec![
62 "show-ref".to_string(),
63 "--verify".to_string(),
64 "--".to_string(),
65 format!("refs/heads/{}", name),
66 ],
67 )
68}
69
70pub fn build_create_branch_cmd(
71 dir: &Path,
72 env: Env,
73 name: &str,
74 start_point: Option<&str>,
75 force: bool,
76) -> Cmd {
77 let mut args = vec!["branch".to_string()];
78 if force {
79 args.push("-f".to_string());
80 }
81 args.push("--end-of-options".to_string());
82 args.push(name.to_string());
83 if let Some(sp) = start_point {
84 args.push(sp.to_string());
85 }
86 share::git_cmd(CALLER, dir, env, None, args)
87}
88
89pub fn build_delete_branch_cmd(dir: &Path, env: Env, name: &str, force: bool) -> Cmd {
90 let flag = if force { "-D" } else { "-d" };
91 share::git_cmd(
92 CALLER,
93 dir,
94 env,
95 None,
96 vec!["branch".to_string(), flag.to_string(), name.to_string()],
97 )
98}
99
100pub fn build_rename_branch_cmd(dir: &Path, env: Env, from: &str, to: &str) -> Cmd {
101 share::git_cmd(
102 CALLER,
103 dir,
104 env,
105 None,
106 vec![
107 "branch".to_string(),
108 "-m".to_string(),
109 from.to_string(),
110 to.to_string(),
111 ],
112 )
113}
114
115pub fn build_update_head_branch_cmd(dir: &Path, env: Env, name: &str) -> Cmd {
116 share::git_cmd(
117 CALLER,
118 dir,
119 env,
120 None,
121 vec![
122 "symbolic-ref".to_string(),
123 "HEAD".to_string(),
124 format!("refs/heads/{}", name),
125 ],
126 )
127}
128
129pub fn build_update_head_detached_cmd(dir: &Path, env: Env, sha: &str) -> Cmd {
130 share::git_cmd(
131 CALLER,
132 dir,
133 env,
134 None,
135 vec![
136 "update-ref".to_string(),
137 "--no-deref".to_string(),
138 "HEAD".to_string(),
139 sha.to_string(),
140 ],
141 )
142}
143
144pub fn classify_stderr(stderr: &str, name: &str) -> BabyError {
145 let s = stderr.trim();
146 if s.contains("already exists") {
147 return BabyError::BranchAlreadyExists(name.to_string());
148 }
149 if s.contains("not found") || s.contains("does not exist") {
150 return BabyError::BranchNotFound(name.to_string());
151 }
152 if s.contains("invalid") && s.contains("branch") {
153 return BabyError::InvalidBranchName(name.to_string());
154 }
155 BabyError::Spawn {
156 prog: "git".to_string(),
157 source: std::io::Error::other(s.to_string()),
158 stderr: s.to_string(),
159 }
160}
161
162pub fn classify_cmd_error(err: crate::command::error::CmdError, name: &str) -> BabyError {
163 use crate::command::error::CmdError;
164 match err {
165 CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, name),
166 CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
167 other => BabyError::from(other),
168 }
169}
170
171pub fn parse_branch_name(output: &[u8]) -> Result<String, BabyError> {
172 let s = std::str::from_utf8(output)
173 .map_err(|e| BabyError::Custom(format!("branch invalid utf8: {}", e)))?;
174 Ok(s.trim().to_string())
175}
176
177pub fn parse_head_default(s: &str) -> Result<String, BabyError> {
178 if s.is_empty() {
179 return Err(BabyError::Custom(
180 "HEAD is not pointing at a branch".to_string(),
181 ));
182 }
183 Ok(s.to_string())
184}
Added lib/gitbaby/src/branch/mod.rs lib/gitbaby/src/branch/mod.rs +5 −0 View lib/gitbaby/src/branch/mod.rs
@@ -0,0 +1,5 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{BranchInfo, HeadTarget, ListBranchesOptions};
Added lib/gitbaby/src/branch/types.rs lib/gitbaby/src/branch/types.rs +18 −0 View lib/gitbaby/src/branch/types.rs
@@ -0,0 +1,18 @@
1use gix::ObjectId;
2use serde::{Deserialize, Serialize};
3#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4pub struct BranchInfo {
5 pub name: String,
6 pub sha: ObjectId,
7}
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct ListBranchesOptions {
11 pub patterns: Vec<String>,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub enum HeadTarget {
16 Branch(String),
17 Detached(ObjectId),
18}
Added lib/gitbaby/src/branch/usecase.rs lib/gitbaby/src/branch/usecase.rs +265 −0 View lib/gitbaby/src/branch/usecase.rs
@@ -0,0 +1,265 @@
1use crate::GitBaby;
2use crate::branch::helper;
3use crate::branch::types::{BranchInfo, HeadTarget, ListBranchesOptions};
4use crate::error::BabyError;
5use crate::refs::types::{ListRefsOptions, RefType};
6use crate::share;
7
8impl GitBaby {
9 pub async fn list_branches(
10 &self,
11 opts: ListBranchesOptions,
12 ) -> Result<Vec<BranchInfo>, BabyError> {
13 helper::validate_list_branches_options(&opts)?;
14 let refs_opts = ListRefsOptions {
15 patterns: opts.patterns.clone(),
16 peel_tags: false,
17 ..Default::default()
18 };
19 let refs = self.list_refs(refs_opts).await?;
20 Ok(refs
21 .into_iter()
22 .filter(|r| matches!(r.ref_type, RefType::Branch))
23 .map(|r| BranchInfo {
24 name: r
25 .name
26 .strip_prefix("refs/heads/")
27 .unwrap_or(&r.name)
28 .to_string(),
29 sha: r.sha,
30 })
31 .collect())
32 }
33
34 pub async fn find_branch(&self, name: &str) -> Result<BranchInfo, BabyError> {
35 share::validate_ref_name(&format!("refs/heads/{}", name))?;
36 let full = format!("refs/heads/{}", name);
37 let refs_opts = ListRefsOptions {
38 patterns: vec![full.clone()],
39 peel_tags: false,
40 ..Default::default()
41 };
42 let refs = self.list_refs(refs_opts).await?;
43 refs.into_iter()
44 .next()
45 .map(|r| BranchInfo {
46 name: r
47 .name
48 .strip_prefix("refs/heads/")
49 .unwrap_or(&r.name)
50 .to_string(),
51 sha: r.sha,
52 })
53 .ok_or_else(|| BabyError::BranchNotFound(name.to_string()))
54 }
55
56 pub async fn branch_exists(&self, name: &str) -> Result<bool, BabyError> {
57 share::validate_ref_name(&format!("refs/heads/{}", name))?;
58 let mut cmd = self
59 .spawn_branch_cmd(|dir, env| helper::build_branch_exists_cmd(dir, env, name))
60 .await?;
61 match cmd.run().await {
62 Ok(out) => Ok(out.status.success()),
63 Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => {
64 let s = stderr.trim();
65 if s.contains("not found") || s.contains("does not exist") || s.is_empty() {
66 Ok(false)
67 } else {
68 Err(helper::classify_stderr(s, name))
69 }
70 }
71 Err(e) => Err(helper::classify_cmd_error(e, name)),
72 }
73 }
74
75 pub async fn get_default_branch(&self) -> Result<String, BabyError> {
76 let mut cmd = self
77 .spawn_branch_cmd(helper::build_get_default_branch_cmd)
78 .await?;
79 let output = cmd
80 .run()
81 .await
82 .map_err(|e| helper::classify_cmd_error(e, ""))?;
83 if !output.status.success() {
84 return Err(BabyError::Custom(format!(
85 "failed to read HEAD: {}",
86 String::from_utf8_lossy(&output.stderr).trim()
87 )));
88 }
89 helper::parse_head_default(&helper::parse_branch_name(&output.stdout)?)
90 }
91
92 pub async fn set_default_branch(&self, name: &str) -> Result<(), BabyError> {
93 share::validate_ref_name(&format!("refs/heads/{}", name))?;
94 let mut cmd = self
95 .spawn_branch_cmd(|dir, env| helper::build_set_default_branch_cmd(dir, env, name))
96 .await?;
97 let output = cmd
98 .run()
99 .await
100 .map_err(|e| helper::classify_cmd_error(e, name))?;
101 if !output.status.success() {
102 return Err(helper::classify_stderr(
103 &String::from_utf8_lossy(&output.stderr),
104 name,
105 ));
106 }
107 let _ = crate::cache::invalidation::hook_ref_updated(self, name).await;
108 Ok(())
109 }
110
111 pub async fn create_branch(
112 &self,
113 name: &str,
114 start_point: Option<&str>,
115 ) -> Result<(), BabyError> {
116 if name.is_empty() {
117 return Err(BabyError::InvalidBranchName(name.to_string()));
118 }
119 share::reject_starts_with_dash(name, "branch name")?;
120 share::validate_ref_name(&format!("refs/heads/{}", name))?;
121 let mut cmd = self
122 .spawn_branch_cmd(|dir, env| {
123 helper::build_create_branch_cmd(dir, env, name, start_point, false)
124 })
125 .await?;
126 let output = cmd
127 .run()
128 .await
129 .map_err(|e| helper::classify_cmd_error(e, name))?;
130 if !output.status.success() {
131 return Err(helper::classify_stderr(
132 &String::from_utf8_lossy(&output.stderr),
133 name,
134 ));
135 }
136 let _ = crate::cache::invalidation::hook_ref_updated(self, name).await;
137 Ok(())
138 }
139
140 pub async fn create_branch_force(
141 &self,
142 name: &str,
143 start_point: Option<&str>,
144 ) -> Result<(), BabyError> {
145 if name.is_empty() {
146 return Err(BabyError::InvalidBranchName(name.to_string()));
147 }
148 share::reject_starts_with_dash(name, "branch name")?;
149 share::validate_ref_name(&format!("refs/heads/{}", name))?;
150 let mut cmd = self
151 .spawn_branch_cmd(|dir, env| {
152 helper::build_create_branch_cmd(dir, env, name, start_point, true)
153 })
154 .await?;
155 let output = cmd
156 .run()
157 .await
158 .map_err(|e| helper::classify_cmd_error(e, name))?;
159 if !output.status.success() {
160 return Err(helper::classify_stderr(
161 &String::from_utf8_lossy(&output.stderr),
162 name,
163 ));
164 }
165 let _ = crate::cache::invalidation::hook_ref_updated(self, name).await;
166 Ok(())
167 }
168
169 pub async fn delete_branch(&self, name: &str, force: bool) -> Result<(), BabyError> {
170 if name.is_empty() {
171 return Err(BabyError::InvalidBranchName(name.to_string()));
172 }
173 let mut cmd = self
174 .spawn_branch_cmd(|dir, env| helper::build_delete_branch_cmd(dir, env, name, force))
175 .await?;
176 let output = cmd
177 .run()
178 .await
179 .map_err(|e| helper::classify_cmd_error(e, name))?;
180 if !output.status.success() {
181 return Err(helper::classify_stderr(
182 &String::from_utf8_lossy(&output.stderr),
183 name,
184 ));
185 }
186 let _ = crate::cache::invalidation::hook_ref_updated(self, name).await;
187 Ok(())
188 }
189
190 pub async fn rename_branch(&self, from: &str, to: &str) -> Result<(), BabyError> {
191 share::validate_ref_name(&format!("refs/heads/{}", from))?;
192 share::validate_ref_name(&format!("refs/heads/{}", to))?;
193 let mut cmd = self
194 .spawn_branch_cmd(|dir, env| helper::build_rename_branch_cmd(dir, env, from, to))
195 .await?;
196 let output = cmd
197 .run()
198 .await
199 .map_err(|e| helper::classify_cmd_error(e, from))?;
200 if !output.status.success() {
201 return Err(helper::classify_stderr(
202 &String::from_utf8_lossy(&output.stderr),
203 from,
204 ));
205 }
206 let _ = crate::cache::invalidation::hook_ref_updated(self, from).await;
207 let _ = crate::cache::invalidation::hook_ref_updated(self, to).await;
208 Ok(())
209 }
210
211 pub async fn update_head(&self, target: HeadTarget) -> Result<(), BabyError> {
212 let mut cmd = match &target {
213 HeadTarget::Branch(name) => {
214 share::validate_ref_name(&format!("refs/heads/{}", name))?;
215 self.spawn_branch_cmd(|dir, env| {
216 helper::build_update_head_branch_cmd(dir, env, name)
217 })
218 .await?
219 }
220 HeadTarget::Detached(oid) => {
221 self.spawn_branch_cmd(|dir, env| {
222 helper::build_update_head_detached_cmd(dir, env, &oid.to_string())
223 })
224 .await?
225 }
226 };
227 let target_name = match &target {
228 HeadTarget::Branch(name) => name.clone(),
229 HeadTarget::Detached(oid) => oid.to_string(),
230 };
231 let output = cmd
232 .run()
233 .await
234 .map_err(|e| helper::classify_cmd_error(e, &target_name))?;
235 if !output.status.success() {
236 return Err(helper::classify_stderr(
237 &String::from_utf8_lossy(&output.stderr),
238 &target_name,
239 ));
240 }
241 if let HeadTarget::Branch(name) = &target {
242 let _ = crate::cache::invalidation::hook_ref_updated(self, name).await;
243 }
244 let _ = crate::cache::invalidation::hook_ref_updated(self, "HEAD").await;
245 Ok(())
246 }
247
248 async fn spawn_branch_cmd<F>(&self, builder: F) -> Result<crate::command::cmd::Cmd, BabyError>
249 where
250 F: FnOnce(&std::path::Path, crate::command::env::Env) -> crate::command::cmd::Cmd,
251 {
252 let dir = self
253 .facade
254 .git_repo_dir()
255 .await
256 .map_err(share::facade_error)?;
257 let alternates = self
258 .facade
259 .git_alternate_object_directories()
260 .await
261 .map_err(share::facade_error)?;
262 let env = share::build_env_safe(&alternates);
263 Ok(builder(&dir, env))
264 }
265}
Added lib/gitbaby/src/cache/config.rs lib/gitbaby/src/cache/config.rs +91 −0 View lib/gitbaby/src/cache/config.rs
@@ -0,0 +1,91 @@
1use std::borrow::Cow;
2use std::path::PathBuf;
3
4pub const DEFAULT_NAME: &str = "gitbaby-blob-cache";
5pub const DEFAULT_MEMORY_CAPACITY_BYTES: usize = 64 * 1024 * 1024;
6pub const DEFAULT_DISK_CAPACITY_BYTES: usize = 1024 * 1024 * 1024;
7
8pub const DEFAULT_LFS_NAME: &str = "gitbaby-lfs-cache";
9pub const DEFAULT_LFS_DISK_CAPACITY_BYTES: usize = 5 * 1024 * 1024 * 1024;
10
11#[derive(Debug, Clone)]
12pub struct CacheConfig {
13 pub memory_capacity_bytes: usize,
14 pub disk_capacity_bytes: usize,
15 pub disk_dir: PathBuf,
16 pub name: Cow<'static, str>,
17 pub lfs: Option<LfsCacheConfig>,
18}
19
20#[derive(Debug, Clone)]
21pub struct LfsCacheConfig {
22 pub memory_capacity_bytes: usize,
23 pub disk_capacity_bytes: usize,
24}
25
26impl Default for CacheConfig {
27 fn default() -> Self {
28 Self {
29 memory_capacity_bytes: DEFAULT_MEMORY_CAPACITY_BYTES,
30 disk_capacity_bytes: DEFAULT_DISK_CAPACITY_BYTES,
31 disk_dir: default_cache_dir(),
32 name: Cow::Borrowed(DEFAULT_NAME),
33 lfs: None,
34 }
35 }
36}
37
38impl Default for LfsCacheConfig {
39 fn default() -> Self {
40 Self {
41 memory_capacity_bytes: DEFAULT_MEMORY_CAPACITY_BYTES,
42 disk_capacity_bytes: DEFAULT_LFS_DISK_CAPACITY_BYTES,
43 }
44 }
45}
46
47impl CacheConfig {
48 pub fn from_env() -> Self {
49 Self {
50 memory_capacity_bytes: env_usize(
51 "GITBABY_CACHE_MEM_BYTES",
52 DEFAULT_MEMORY_CAPACITY_BYTES,
53 ),
54 disk_capacity_bytes: env_usize("GITBABY_CACHE_DISK_BYTES", DEFAULT_DISK_CAPACITY_BYTES),
55 disk_dir: std::env::var_os("GITBABY_CACHE_DIR")
56 .map(PathBuf::from)
57 .unwrap_or_else(default_cache_dir),
58 name: Cow::Borrowed(DEFAULT_NAME),
59 lfs: match env_usize(
60 "GITBABY_CACHE_LFS_DISK_BYTES",
61 DEFAULT_LFS_DISK_CAPACITY_BYTES,
62 ) {
63 0 => None,
64 disk_capacity_bytes => Some(LfsCacheConfig {
65 memory_capacity_bytes: DEFAULT_MEMORY_CAPACITY_BYTES,
66 disk_capacity_bytes,
67 }),
68 },
69 }
70 }
71}
72
73fn env_usize(key: &str, default: usize) -> usize {
74 std::env::var(key)
75 .ok()
76 .and_then(|v| v.trim().parse::<usize>().ok())
77 .unwrap_or(default)
78}
79
80fn default_cache_dir() -> PathBuf {
81 if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
82 return PathBuf::from(xdg).join("gitbaby");
83 }
84 if let Some(local) = std::env::var_os("LOCALAPPDATA") {
85 return PathBuf::from(local).join("gitbaby");
86 }
87 if let Some(home) = std::env::var_os("HOME") {
88 return PathBuf::from(home).join(".cache").join("gitbaby");
89 }
90 std::env::temp_dir().join("gitbaby")
91}
Added lib/gitbaby/src/cache/global.rs lib/gitbaby/src/cache/global.rs +74 −0 View lib/gitbaby/src/cache/global.rs
@@ -0,0 +1,74 @@
1use std::sync::{Arc, LazyLock, Mutex};
2
3use crate::cache::{CacheConfig, CachePools};
4use crate::error::BabyError;
5
6pub static GITBABY_CACHE: LazyLock<Mutex<Option<Arc<CachePools>>>> =
7 LazyLock::new(|| Mutex::new(None));
8
9pub fn try_global_cache() -> Option<Arc<CachePools>> {
10 let guard = GITBABY_CACHE.lock().ok()?;
11 guard.clone()
12}
13
14pub fn init_global_cache(cfg: CacheConfig) -> Result<Arc<CachePools>, BabyError> {
15 if let Some(pools) = try_global_cache() {
16 return Ok(pools);
17 }
18 // `block_on` panics when called from within a running tokio runtime.
19 if tokio::runtime::Handle::try_current().is_ok() {
20 return Err(BabyError::Custom(
21 "init_global_cache must not be called from inside a tokio runtime; \
22 use init_global_cache_async instead"
23 .to_string(),
24 ));
25 }
26 let rt = tokio::runtime::Builder::new_current_thread()
27 .enable_all()
28 .build()
29 .map_err(|source| {
30 BabyError::Custom(format!(
31 "build one-shot tokio runtime for init_global_cache: {}",
32 source
33 ))
34 })?;
35 let pools = rt.block_on(CachePools::open(cfg))?;
36 install(pools)
37}
38
39pub async fn init_global_cache_async(cfg: CacheConfig) -> Result<Arc<CachePools>, BabyError> {
40 if let Some(pools) = try_global_cache() {
41 return Ok(pools);
42 }
43 let pools = CachePools::open(cfg).await?;
44 install(pools)
45}
46
47fn install(pools: Arc<CachePools>) -> Result<Arc<CachePools>, BabyError> {
48 let mut guard = GITBABY_CACHE
49 .lock()
50 .map_err(|source| BabyError::Custom(format!("global cache mutex poisoned: {}", source)))?;
51 // If another caller already installed a cache, reuse it rather than
52 // overwriting the global pointer (which would leak the old store and open
53 // a second foyer instance on the same disk directory).
54 if let Some(existing) = guard.as_ref() {
55 return Ok(existing.clone());
56 }
57 *guard = Some(pools.clone());
58 Ok(pools)
59}
60
61pub async fn shutdown_global_cache() -> Result<(), BabyError> {
62 let pools = {
63 let mut guard = GITBABY_CACHE.lock().map_err(|source| {
64 BabyError::Custom(format!("global cache mutex poisoned: {}", source))
65 })?;
66 guard.take()
67 };
68 if let Some(pools) = pools
69 && Arc::strong_count(&pools) == 1
70 {
71 pools.close().await?;
72 }
73 Ok(())
74}
Added lib/gitbaby/src/cache/invalidation.rs lib/gitbaby/src/cache/invalidation.rs +39 −0 View lib/gitbaby/src/cache/invalidation.rs
@@ -0,0 +1,39 @@
1use crate::GitBaby;
2use crate::cache::key::{blob_exists_key, rev_key};
3use crate::cache::try_global_cache;
4use crate::error::BabyError;
5
6fn canonical_repo_dir(raw: std::path::PathBuf) -> Result<std::path::PathBuf, BabyError> {
7 std::fs::canonicalize(&raw).map_err(|source| BabyError::Io { path: raw, source })
8}
9
10pub async fn hook_ref_updated(git: &GitBaby, name: &str) -> Result<(), BabyError> {
11 let Some(pools) = try_global_cache() else {
12 return Ok(());
13 };
14 let repo_dir = git.facade().git_repo_dir().await?;
15 let repo_dir = canonical_repo_dir(repo_dir)?;
16 pools.store().remove(&rev_key(&repo_dir, name));
17 Ok(())
18}
19
20pub async fn hook_blob_written(git: &GitBaby, oid: &gix::ObjectId) -> Result<(), BabyError> {
21 let Some(pools) = try_global_cache() else {
22 return Ok(());
23 };
24 let repo_dir = git.facade().git_repo_dir().await?;
25 let repo_dir = canonical_repo_dir(repo_dir)?;
26 pools.store().remove(&blob_exists_key(&repo_dir, oid));
27 Ok(())
28}
29
30/// Invalidate the entire main cache store. Call after operations that can
31/// change many refs and/or prune objects at once (e.g. `git fetch`,
32/// `git gc --prune=now`, `git fast-import`), where per-key invalidation would
33/// be incomplete.
34pub async fn flush_cache() -> Result<(), BabyError> {
35 let Some(pools) = try_global_cache() else {
36 return Ok(());
37 };
38 pools.clear().await
39}
Added lib/gitbaby/src/cache/key.rs lib/gitbaby/src/cache/key.rs +49 −0 View lib/gitbaby/src/cache/key.rs
@@ -0,0 +1,49 @@
1use std::path::Path;
2
3use crate::cache::CacheKey;
4
5pub const NS_BLOB: u8 = b'B';
6pub const NS_BLOB_SIZE: u8 = b'S';
7pub const NS_BLOB_EXISTS: u8 = b'E';
8pub const NS_COMMIT: u8 = b'C';
9pub const NS_REV: u8 = b'r';
10pub const NS_TREE_ENTRIES: u8 = b't';
11pub const NS_LFS: u8 = b'l';
12
13pub fn encode(ns: u8, suffix: &[u8]) -> CacheKey {
14 let mut key = Vec::with_capacity(1 + suffix.len());
15 key.push(ns);
16 key.extend_from_slice(suffix);
17 key
18}
19
20pub fn object_key(ns: u8, oid: &gix::ObjectId) -> CacheKey {
21 encode(ns, oid.as_bytes())
22}
23
24pub fn rev_key(repo_dir: &Path, revision: &str) -> CacheKey {
25 let path = repo_dir.as_os_str().as_encoded_bytes();
26 let mut key = Vec::with_capacity(1 + 4 + path.len() + 1 + revision.len());
27 key.push(NS_REV);
28 key.extend_from_slice(&(path.len() as u32).to_le_bytes());
29 key.extend_from_slice(path);
30 key.push(0);
31 key.extend_from_slice(revision.as_bytes());
32 key
33}
34
35/// Key for a per-repository blob-existence probe. Blob *existence* is
36/// repository-scoped (a given oid may be present in one repo's object store but
37/// absent in another), unlike blob *content* which is content-addressed. The
38/// canonicalised repo directory is therefore part of the key.
39pub fn blob_exists_key(repo_dir: &Path, oid: &gix::ObjectId) -> CacheKey {
40 let path = repo_dir.as_os_str().as_encoded_bytes();
41 let oid_bytes = oid.as_bytes();
42 let mut key = Vec::with_capacity(1 + 4 + path.len() + 1 + oid_bytes.len());
43 key.push(NS_BLOB_EXISTS);
44 key.extend_from_slice(&(path.len() as u32).to_le_bytes());
45 key.extend_from_slice(path);
46 key.push(0);
47 key.extend_from_slice(oid_bytes);
48 key
49}
Added lib/gitbaby/src/cache/mod.rs lib/gitbaby/src/cache/mod.rs +22 −0 View lib/gitbaby/src/cache/mod.rs
@@ -0,0 +1,22 @@
1pub mod config;
2pub mod global;
3pub mod invalidation;
4pub mod key;
5pub mod pools;
6pub mod stats;
7pub mod store;
8
9pub use config::{
10 CacheConfig, DEFAULT_DISK_CAPACITY_BYTES, DEFAULT_LFS_DISK_CAPACITY_BYTES, DEFAULT_LFS_NAME,
11 DEFAULT_MEMORY_CAPACITY_BYTES, DEFAULT_NAME, LfsCacheConfig,
12};
13pub use global::{
14 GITBABY_CACHE, init_global_cache, init_global_cache_async, shutdown_global_cache,
15 try_global_cache,
16};
17pub use key::{
18 NS_BLOB, NS_BLOB_EXISTS, NS_BLOB_SIZE, NS_COMMIT, NS_LFS, NS_REV, NS_TREE_ENTRIES,
19 blob_exists_key, encode, object_key, rev_key,
20};
21pub use pools::CachePools;
22pub use store::{CacheKey, CacheStore, CacheValue};
Added lib/gitbaby/src/cache/pools.rs lib/gitbaby/src/cache/pools.rs +66 −0 View lib/gitbaby/src/cache/pools.rs
@@ -0,0 +1,66 @@
1use std::borrow::Cow;
2use std::sync::Arc;
3
4use crate::cache::config::DEFAULT_LFS_NAME;
5use crate::cache::stats::CacheStats;
6use crate::cache::{CacheConfig, CacheStore};
7use crate::error::BabyError;
8
9pub struct CachePools {
10 store: Arc<CacheStore>,
11 lfs: Option<Arc<CacheStore>>,
12}
13
14impl CachePools {
15 pub async fn open(cfg: CacheConfig) -> Result<Arc<Self>, BabyError> {
16 let store = CacheStore::open(cfg.clone()).await?;
17 let lfs = match &cfg.lfs {
18 Some(lfs_cfg) => {
19 let lfs_cfg = CacheConfig {
20 memory_capacity_bytes: lfs_cfg.memory_capacity_bytes,
21 disk_capacity_bytes: lfs_cfg.disk_capacity_bytes,
22 disk_dir: cfg.disk_dir.join("lfs"),
23 name: Cow::Owned(DEFAULT_LFS_NAME.to_owned()),
24 lfs: None,
25 };
26 Some(CacheStore::open(lfs_cfg).await?)
27 }
28 None => None,
29 };
30 Ok(Arc::new(Self { store, lfs }))
31 }
32
33 pub async fn close(&self) -> Result<(), BabyError> {
34 self.store.close().await?;
35 if let Some(lfs) = &self.lfs {
36 lfs.close().await?;
37 }
38 Ok(())
39 }
40
41 /// Drop all entries in the main object/ref store. The LFS store is
42 /// content-addressed by LFS oid and unaffected by git object/ref churn, so
43 /// it is left intact.
44 pub async fn clear(&self) -> Result<(), BabyError> {
45 self.store.clear().await
46 }
47
48 pub fn store(&self) -> &Arc<CacheStore> {
49 &self.store
50 }
51
52 pub fn lfs(&self) -> Option<&Arc<CacheStore>> {
53 self.lfs.as_ref()
54 }
55
56 pub fn stats(&self) -> CacheStats {
57 let s = self.store.statistics();
58 CacheStats {
59 name: self.store.config().name.to_string(),
60 disk_write_bytes: s.disk_write_bytes() as u64,
61 disk_read_bytes: s.disk_read_bytes() as u64,
62 disk_write_ios: s.disk_write_ios() as u64,
63 disk_read_ios: s.disk_read_ios() as u64,
64 }
65 }
66}
Added lib/gitbaby/src/cache/stats.rs lib/gitbaby/src/cache/stats.rs +8 −0 View lib/gitbaby/src/cache/stats.rs
@@ -0,0 +1,8 @@
1#[derive(Debug, Clone)]
2pub struct CacheStats {
3 pub name: String,
4 pub disk_write_bytes: u64,
5 pub disk_read_bytes: u64,
6 pub disk_write_ios: u64,
7 pub disk_read_ios: u64,
8}
Added lib/gitbaby/src/cache/store.rs lib/gitbaby/src/cache/store.rs +116 −0 View lib/gitbaby/src/cache/store.rs
@@ -0,0 +1,116 @@
1use std::sync::Arc;
2
3use foyer::{BlockEngineConfig, DeviceBuilder, FsDeviceBuilder, HybridCache, HybridCacheBuilder};
4
5use crate::cache::CacheConfig;
6use crate::error::BabyError;
7
8pub type CacheKey = Vec<u8>;
9pub type CacheValue = Vec<u8>;
10
11pub struct CacheStore {
12 inner: HybridCache<CacheKey, CacheValue>,
13 cfg: CacheConfig,
14}
15
16impl std::fmt::Debug for CacheStore {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 f.debug_struct("CacheStore")
19 .field("name", &self.cfg.name)
20 .field("disk_dir", &self.cfg.disk_dir)
21 .finish()
22 }
23}
24
25impl CacheStore {
26 pub async fn open(cfg: CacheConfig) -> Result<Arc<Self>, BabyError> {
27 std::fs::create_dir_all(&cfg.disk_dir).map_err(|source| BabyError::Io {
28 path: cfg.disk_dir.clone(),
29 source,
30 })?;
31
32 let device = FsDeviceBuilder::new(&cfg.disk_dir)
33 .with_capacity(cfg.disk_capacity_bytes)
34 .build()
35 .map_err(|source| BabyError::Cache {
36 op: "device_build",
37 source,
38 })?;
39
40 let inner: HybridCache<CacheKey, CacheValue> = HybridCacheBuilder::new()
41 .with_name(cfg.name.clone())
42 .memory(cfg.memory_capacity_bytes)
43 .storage()
44 .with_engine_config(BlockEngineConfig::new(device))
45 .build()
46 .await
47 .map_err(|source| BabyError::Cache {
48 op: "build",
49 source,
50 })?;
51
52 Ok(Arc::new(Self { inner, cfg }))
53 }
54
55 pub async fn close(&self) -> Result<(), BabyError> {
56 self.inner.close().await.map_err(|source| BabyError::Cache {
57 op: "close",
58 source,
59 })
60 }
61
62 /// Drop all cached entries. Used to invalidate the whole store after an
63 /// operation that may change or prune many objects/refs at once (e.g.
64 /// `git fetch`, `git gc --prune=now`, `git fast-import`).
65 pub async fn clear(&self) -> Result<(), BabyError> {
66 self.inner.clear().await.map_err(|source| BabyError::Cache {
67 op: "clear",
68 source,
69 })
70 }
71
72 pub fn config(&self) -> &CacheConfig {
73 &self.cfg
74 }
75
76 pub fn statistics(&self) -> Arc<foyer::Statistics> {
77 Arc::clone(self.inner.statistics())
78 }
79
80 pub async fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, BabyError> {
81 let entry = self
82 .inner
83 .get(key)
84 .await
85 .map_err(|source| BabyError::Cache { op: "get", source })?;
86 Ok(entry.map(|e| e.value().clone()))
87 }
88
89 pub fn insert(&self, key: Vec<u8>, value: Vec<u8>) {
90 self.inner.insert(key, value);
91 }
92
93 pub fn contains(&self, key: &[u8]) -> bool {
94 self.inner.contains(key)
95 }
96
97 pub fn remove(&self, key: &[u8]) {
98 self.inner.remove(key);
99 }
100
101 pub async fn get_or_fetch<F, Fut>(&self, key: Vec<u8>, fetch: F) -> Result<Vec<u8>, BabyError>
102 where
103 F: FnOnce() -> Fut,
104 Fut: std::future::Future<Output = Result<Vec<u8>, BabyError>> + Send + 'static,
105 {
106 let entry = self
107 .inner
108 .get_or_fetch(&key, fetch)
109 .await
110 .map_err(|source| BabyError::Cache {
111 op: "get_or_fetch",
112 source,
113 })?;
114 Ok(entry.value().clone())
115 }
116}
Added lib/gitbaby/src/cleanup/helper.rs lib/gitbaby/src/cleanup/helper.rs +19 −0 View lib/gitbaby/src/cleanup/helper.rs
@@ -0,0 +1,19 @@
1use std::path::Path;
2
3use crate::command::cmd::Cmd;
4use crate::command::env::Env;
5
6pub fn build_gc_cmd(caller: &str, dir: &Path, env: Env, prune_now: bool) -> Cmd {
7 crate::share::build_gc_cmd(caller, dir, env, prune_now)
8}
9
10pub fn build_pack_refs_cmd(caller: &str, dir: &Path, env: Env) -> Cmd {
11 crate::share::build_pack_refs_cmd(caller, dir, env)
12}
13
14pub fn parse_pack_refs_output(output: &[u8]) -> u64 {
15 String::from_utf8_lossy(output)
16 .lines()
17 .filter(|l| !l.trim().is_empty())
18 .count() as u64
19}
Added lib/gitbaby/src/cleanup/mod.rs lib/gitbaby/src/cleanup/mod.rs +5 −0 View lib/gitbaby/src/cleanup/mod.rs
@@ -0,0 +1,5 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{CleanupStats, PruneResult};
Added lib/gitbaby/src/cleanup/types.rs lib/gitbaby/src/cleanup/types.rs +11 −0 View lib/gitbaby/src/cleanup/types.rs
@@ -0,0 +1,11 @@
1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4pub struct PruneResult {
5 pub packed_refs: u64,
6}
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9pub struct CleanupStats {
10 pub removed_internal_refs: u64,
11}
Added lib/gitbaby/src/cleanup/usecase.rs lib/gitbaby/src/cleanup/usecase.rs +62 −0 View lib/gitbaby/src/cleanup/usecase.rs
@@ -0,0 +1,62 @@
1use std::path::Path;
2
3use crate::GitBaby;
4use crate::cleanup::helper;
5use crate::cleanup::types::{CleanupStats, PruneResult};
6use crate::error::BabyError;
7
8async fn build_env(
9 gitbaby: &GitBaby,
10) -> Result<(std::path::PathBuf, crate::command::env::Env), BabyError> {
11 let dir = gitbaby
12 .facade
13 .git_repo_dir()
14 .await
15 .map_err(crate::share::facade_error)?;
16 let alternates = gitbaby
17 .facade
18 .git_alternate_object_directories()
19 .await
20 .map_err(crate::share::facade_error)?;
21 Ok((dir, crate::share::build_env_safe(&alternates)))
22}
23
24impl GitBaby {
25 pub async fn apply_bfg_object_map(&self, bytes: &[u8]) -> Result<CleanupStats, BabyError> {
26 let _ = build_env(self).await?;
27 let text = std::str::from_utf8(bytes)
28 .map_err(|e| BabyError::Custom(format!("object map is not valid UTF-8: {}", e)))?;
29 let pairs = crate::share::parse_object_map(text)?;
30 let total_removed: u64 = pairs.len() as u64;
31 Ok(CleanupStats {
32 removed_internal_refs: total_removed,
33 })
34 }
35
36 pub async fn prune(&self) -> Result<PruneResult, BabyError> {
37 let (dir, env) = build_env(self).await?;
38 let mut gc = helper::build_gc_cmd("gitbaby::cleanup", &dir, env.clone(), true);
39 let gc_out = gc.run_soft().await.map_err(BabyError::from)?;
40 if !gc_out.status.success() {
41 return Err(BabyError::GcFailed(
42 String::from_utf8_lossy(&gc_out.stderr).trim().to_string(),
43 ));
44 }
45 let mut pr = helper::build_pack_refs_cmd("gitbaby::cleanup", &dir, env);
46 let pr_out = pr.run_soft().await.map_err(BabyError::from)?;
47 if !pr_out.status.success() {
48 return Err(BabyError::GcFailed(format!(
49 "pack-refs failed: {}",
50 String::from_utf8_lossy(&pr_out.stderr).trim()
51 )));
52 }
53 // gc --prune=now may have removed objects, and pack-refs rewrote the
54 // packed-refs file; cached rev/object entries are now stale.
55 let _ = crate::cache::invalidation::flush_cache().await;
56 Ok(PruneResult {
57 packed_refs: helper::parse_pack_refs_output(&pr_out.stdout),
58 })
59 }
60}
61
62fn _path_marker(_p: &Path) {}
Added lib/gitbaby/src/command/cmd.rs lib/gitbaby/src/command/cmd.rs +383 −0 View lib/gitbaby/src/command/cmd.rs
@@ -0,0 +1,383 @@
1use crate::command::env::Env;
2use crate::command::error::{CmdError, CmdResult};
3use std::fmt::{Debug, Display, Formatter};
4use std::path::PathBuf;
5use std::process::{ExitStatus, Stdio};
6use time::UtcDateTime;
7use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
8use tokio::process::{Child, Command as TokioCommand};
9
10pub struct Cmd {
11 pub caller_info: String,
12 pub prog: String,
13 pub args: Vec<String>,
14 pub(crate) config_args: Vec<String>,
15 pub dir: PathBuf,
16 pub cmd: Option<Child>,
17 pub start: UtcDateTime,
18 pub env: Env,
19 pub stdin: Box<dyn AsyncWrite + Unpin + Send + Sync>,
20 pub stdout: Box<dyn AsyncRead + Unpin + Send + Sync>,
21 pub stderr: Box<dyn AsyncRead + Unpin + Send + Sync>,
22 pub timeout: Option<u64>,
23 pub output_cap: usize,
24 /// Set once `feed()` has written and closed stdin, so a second `feed()`
25 /// cannot silently discard data.
26 pub stdin_fed: bool,
27 /// Set once the child process has been taken out of `self.cmd` (reaped by
28 /// `wait()` or handed to the reaper by `cancel_pipe()`). A finished `Cmd`
29 /// must not be spawned again.
30 pub finished: bool,
31}
32
33pub const DEFAULT_OUTPUT_CAP_BYTES: usize = 64 * 1024 * 1024;
34
35#[derive(Debug, Clone)]
36pub struct CmdOutput {
37 pub status: ExitStatus,
38 pub stdout: Vec<u8>,
39 pub stderr: Vec<u8>,
40}
41
42impl Clone for Cmd {
43 fn clone(&self) -> Self {
44 Self {
45 caller_info: self.caller_info.clone(),
46 prog: self.prog.clone(),
47 args: self.args.clone(),
48 config_args: self.config_args.clone(),
49 dir: self.dir.clone(),
50 cmd: None,
51 start: self.start,
52 env: self.env.clone(),
53 stdin: Box::new(tokio::io::sink()),
54 stdout: Box::new(tokio::io::empty()),
55 stderr: Box::new(tokio::io::empty()),
56 timeout: self.timeout,
57 output_cap: self.output_cap,
58 stdin_fed: false,
59 finished: false,
60 }
61 }
62}
63
64impl Debug for Cmd {
65 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
66 f.debug_struct("Cmd")
67 .field("caller_info", &self.caller_info)
68 .field("prog", &self.prog)
69 .field("args", &self.args)
70 .field("config_args", &self.config_args)
71 .field("dir", &self.dir)
72 .field("start", &self.start)
73 .field("env", &self.env)
74 .field("timeout", &self.timeout)
75 .field("cmd", &self.cmd.as_ref().map(|_| "<running>"))
76 .finish_non_exhaustive()
77 }
78}
79
80impl Display for Cmd {
81 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
82 let mut parts: Vec<&str> = Vec::with_capacity(1 + self.config_args.len() + self.args.len());
83 parts.push(&self.prog);
84 for a in &self.config_args {
85 parts.push(a);
86 }
87 for a in &self.args {
88 parts.push(a);
89 }
90 write!(f, "{}", parts.join(" "))
91 }
92}
93
94impl Cmd {
95 pub fn new(
96 caller_info: impl Into<String>,
97 prog: impl Into<String>,
98 args: Vec<String>,
99 config_args: Vec<String>,
100 dir: PathBuf,
101 env: impl Into<Env>,
102 timeout: Option<u64>,
103 ) -> Self {
104 Self {
105 caller_info: caller_info.into(),
106 prog: prog.into(),
107 args,
108 config_args,
109 dir,
110 cmd: None,
111 start: UtcDateTime::now(),
112 env: env.into(),
113 stdin: Box::new(tokio::io::sink()),
114 stdout: Box::new(tokio::io::empty()),
115 stderr: Box::new(tokio::io::empty()),
116 timeout,
117 output_cap: DEFAULT_OUTPUT_CAP_BYTES,
118 stdin_fed: false,
119 finished: false,
120 }
121 }
122
123 pub fn build_command(&self) -> TokioCommand {
124 let mut command = TokioCommand::new(&self.prog);
125 for cfg in &self.config_args {
126 command.arg(cfg);
127 }
128 for arg in &self.args {
129 command.arg(arg);
130 }
131 command.current_dir(&self.dir);
132 command.stdin(Stdio::piped());
133 command.stdout(Stdio::piped());
134 command.stderr(Stdio::piped());
135 for (k, v) in self.env.iter() {
136 command.env(k, v);
137 }
138 command
139 }
140
141 pub async fn spawn(&mut self) -> CmdResult<()> {
142 if self.finished {
143 return Err(CmdError::Custom(format!(
144 "process `{}` already finished and cannot be spawned again",
145 self.prog
146 )));
147 }
148 if self.cmd.is_some() {
149 return Err(CmdError::Custom(format!(
150 "process `{}` already spawned",
151 self.prog
152 )));
153 }
154 self.start = UtcDateTime::now();
155 let mut command = self.build_command();
156 let mut child = command.spawn().map_err(|source| CmdError::Spawn {
157 prog: self.prog.clone(),
158 source,
159 })?;
160
161 let stdin = child
162 .stdin
163 .take()
164 .ok_or_else(|| CmdError::Custom("stdin was not piped".into()))?;
165 let stdout = child
166 .stdout
167 .take()
168 .ok_or_else(|| CmdError::Custom("stdout was not piped".into()))?;
169 let stderr = child
170 .stderr
171 .take()
172 .ok_or_else(|| CmdError::Custom("stderr was not piped".into()))?;
173
174 self.stdin = Box::new(stdin);
175 self.stdout = Box::new(stdout);
176 self.stderr = Box::new(stderr);
177 self.cmd = Some(child);
178 Ok(())
179 }
180
181 pub async fn feed(&mut self, input: &[u8]) -> CmdResult<()> {
182 if self.stdin_fed {
183 return Err(CmdError::Custom("stdin was already fed".into()));
184 }
185 if self.cmd.is_none() {
186 self.spawn().await?;
187 }
188 self.stdin_fed = true;
189 let mut writer = std::mem::replace(&mut self.stdin, Box::new(tokio::io::sink()));
190
191 let write_fut = async {
192 writer.write_all(input).await?;
193 writer.shutdown().await
194 };
195
196 if let Some(secs) = self.timeout {
197 match tokio::time::timeout(std::time::Duration::from_secs(secs), write_fut).await {
198 Ok(res) => {
199 res.map_err(|source| CmdError::Io {
200 path: self.dir.clone(),
201 source,
202 })?;
203 }
204 Err(_) => {
205 if let Some(child) = self.cmd.as_mut() {
206 let _ = child.start_kill();
207 }
208 return Err(CmdError::Timeout {
209 prog: self.prog.clone(),
210 timeout_ms: secs.saturating_mul(1000),
211 });
212 }
213 }
214 } else {
215 write_fut.await.map_err(|source| CmdError::Io {
216 path: self.dir.clone(),
217 source,
218 })?;
219 }
220 Ok(())
221 }
222
223 pub async fn wait(&mut self) -> CmdResult<ExitStatus> {
224 let mut child = self
225 .cmd
226 .take()
227 .ok_or_else(|| CmdError::Custom("process not spawned yet".into()))?;
228 self.finished = true;
229 let wait_fut = child.wait();
230 let status = if let Some(secs) = self.timeout {
231 match tokio::time::timeout(std::time::Duration::from_secs(secs), wait_fut).await {
232 Ok(res) => res,
233 Err(_) => {
234 // Kill, then reap so the child does not linger as a zombie.
235 if let Err(source) = child.start_kill() {
236 return Err(CmdError::Io {
237 path: self.dir.clone(),
238 source,
239 });
240 }
241 let _ = child.wait().await;
242 return Err(CmdError::Timeout {
243 prog: self.prog.clone(),
244 timeout_ms: secs.saturating_mul(1000),
245 });
246 }
247 }
248 } else {
249 wait_fut.await
250 };
251 status.map_err(|source| CmdError::Io {
252 path: self.dir.clone(),
253 source,
254 })
255 }
256
257 pub async fn run(&mut self) -> CmdResult<CmdOutput> {
258 self.run_inner(false).await
259 }
260
261 /// Run the command but treat a non-zero exit as a *successful* run whose
262 /// `CmdOutput.status` carries the failure, instead of returning
263 /// `CmdError::NonZeroExit`.
264 ///
265 /// Use this when the caller wants to inspect exit codes / stderr itself
266 /// (e.g. classifying "does not exist" vs real errors).
267 pub async fn run_soft(&mut self) -> CmdResult<CmdOutput> {
268 self.run_inner(true).await
269 }
270
271 async fn run_inner(&mut self, soft: bool) -> CmdResult<CmdOutput> {
272 if self.cmd.is_none() {
273 self.spawn().await?;
274 }
275 let stdout = std::mem::replace(&mut self.stdout, Box::new(tokio::io::empty()));
276 let stderr = std::mem::replace(&mut self.stderr, Box::new(tokio::io::empty()));
277 let cap = self.output_cap;
278
279 let read_out = read_drain_capped(stdout, cap);
280 let read_err = read_drain_capped(stderr, cap);
281 let wait_fut = self.wait();
282
283 let (out_res, err_res, wait_res) = tokio::join!(read_out, read_err, wait_fut);
284
285 let (stdout, stdout_total) = out_res.map_err(|source| CmdError::Io {
286 path: self.dir.clone(),
287 source,
288 })?;
289 let (stderr, stderr_total) = err_res.map_err(|source| CmdError::Io {
290 path: self.dir.clone(),
291 source,
292 })?;
293
294 if stdout_total > cap as u64 {
295 return Err(CmdError::PayloadTooLarge {
296 cap_bytes: cap as u64,
297 actual_bytes: stdout_total,
298 });
299 }
300 if stderr_total > cap as u64 {
301 return Err(CmdError::PayloadTooLarge {
302 cap_bytes: cap as u64,
303 actual_bytes: stderr_total,
304 });
305 }
306
307 let status = wait_res?;
308
309 if !status.success() && !soft {
310 return Err(CmdError::NonZeroExit {
311 prog: self.prog.clone(),
312 args: self.args.clone(),
313 code: status.code().unwrap_or(-1),
314 stderr: String::from_utf8_lossy(&stderr).into_owned(),
315 });
316 }
317
318 Ok(CmdOutput {
319 status,
320 stdout,
321 stderr,
322 })
323 }
324
325 pub async fn kill(&mut self) -> CmdResult<()> {
326 if let Some(child) = self.cmd.as_mut() {
327 child.start_kill().map_err(|source| CmdError::Io {
328 path: self.dir.clone(),
329 source,
330 })?;
331 }
332 Ok(())
333 }
334
335 pub fn try_wait(&mut self) -> CmdResult<Option<ExitStatus>> {
336 let child = self
337 .cmd
338 .as_mut()
339 .ok_or_else(|| CmdError::Custom("process not spawned yet".into()))?;
340 child.try_wait().map_err(|source| CmdError::Io {
341 path: self.dir.clone(),
342 source,
343 })
344 }
345
346 pub fn is_running(&self) -> bool {
347 self.cmd.is_some() && !self.finished
348 }
349
350 #[must_use]
351 pub fn with_dir(&self, dir: PathBuf) -> Self {
352 let mut cloned = self.clone();
353 cloned.dir = dir;
354 cloned
355 }
356}
357
358/// Read `reader` to completion while capping the retained buffer at `cap`.
359///
360/// To avoid deadlocking the child (which would block writing once the pipe
361/// buffer fills), bytes beyond `cap` are still drained but discarded. Returns
362/// the retained prefix and the *total* number of bytes drained, so the caller
363/// can distinguish "exactly at cap" from "overflowed".
364async fn read_drain_capped<R>(mut reader: R, cap: usize) -> std::io::Result<(Vec<u8>, u64)>
365where
366 R: AsyncRead + Unpin,
367{
368 let mut buf: Vec<u8> = Vec::with_capacity(cap.min(64 * 1024));
369 let mut total: u64 = 0;
370 let mut chunk = [0u8; 8192];
371 loop {
372 let n = reader.read(&mut chunk).await?;
373 if n == 0 {
374 break;
375 }
376 total = total.saturating_add(n as u64);
377 if buf.len() < cap {
378 let space = cap - buf.len();
379 buf.extend_from_slice(&chunk[..n.min(space)]);
380 }
381 }
382 Ok((buf, total))
383}
Added lib/gitbaby/src/command/context.rs lib/gitbaby/src/command/context.rs +11 −0 View lib/gitbaby/src/command/context.rs
@@ -0,0 +1,11 @@
1pub trait GitPipeContext {
2 fn cancel_pipe(&mut self);
3
4 fn current_index(&self) -> Option<usize> {
5 None
6 }
7
8 fn on_command_finished(&mut self, _index: usize) {}
9
10 fn on_progress(&mut self, _index: usize, _bytes: u64) {}
11}
Added lib/gitbaby/src/command/env.rs lib/gitbaby/src/command/env.rs +159 −0 View lib/gitbaby/src/command/env.rs
@@ -0,0 +1,159 @@
1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::fmt;
4
5#[derive(Default, Clone)]
6pub struct Env {
7 pairs: Vec<(String, String)>,
8}
9
10impl Env {
11 pub fn new() -> Self {
12 Self::default()
13 }
14
15 pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
16 self.pairs.push((key.into(), value.into()));
17 self
18 }
19
20 pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
21 let key = key.into();
22 self.pairs.retain(|(k, _)| k != &key);
23 self.pairs.push((key, value.into()));
24 }
25
26 pub fn unset(&mut self, key: &str) {
27 self.pairs.retain(|(k, _)| k != key);
28 }
29
30 pub fn get(&self, key: &str) -> Option<&str> {
31 self.pairs
32 .iter()
33 .find(|(k, _)| k == key)
34 .map(|(_, v)| v.as_str())
35 }
36
37 pub fn as_pairs(&self) -> &[(String, String)] {
38 &self.pairs
39 }
40
41 pub fn into_pairs(self) -> Vec<(String, String)> {
42 self.pairs
43 }
44
45 pub fn iter(&self) -> std::slice::Iter<'_, (String, String)> {
46 self.pairs.iter()
47 }
48
49 pub fn len(&self) -> usize {
50 self.pairs.len()
51 }
52
53 pub fn is_empty(&self) -> bool {
54 self.pairs.is_empty()
55 }
56
57 pub fn append(&mut self, other: &Env) {
58 for (k, v) in other.iter() {
59 self.set(k.clone(), v.clone());
60 }
61 }
62
63 pub fn from_current_env() -> Self {
64 Self {
65 pairs: std::env::vars().collect(),
66 }
67 }
68
69 pub fn with_current_env(mut self) -> Self {
70 for (k, v) in std::env::vars() {
71 self.set(k, v);
72 }
73 self
74 }
75
76 pub fn overlay_current_env(mut self) -> Self {
77 let existing: HashSet<String> = self.pairs.iter().map(|(k, _)| k.clone()).collect();
78 for (k, v) in std::env::vars() {
79 if !existing.contains(&k) {
80 self.pairs.push((k, v));
81 }
82 }
83 self
84 }
85}
86
87impl fmt::Debug for Env {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 let entries: Vec<(&str, String)> = self
90 .pairs
91 .iter()
92 .map(|(k, v)| (k.as_str(), masked_value(k, v)))
93 .collect();
94 f.debug_map()
95 .entries(entries.iter().map(|(k, v)| (*k, v)))
96 .finish()
97 }
98}
99
100fn is_sensitive_key(key: &str) -> bool {
101 let lower = key.to_ascii_lowercase();
102 let markers = [
103 "token",
104 "secret",
105 "password",
106 "passwd",
107 "apikey",
108 "api_key",
109 "credential",
110 "authorization",
111 "private_key",
112 "session",
113 "access_key",
114 "secret_key",
115 ];
116 markers.iter().any(|m| lower.contains(m))
117}
118
119fn masked_value(key: &str, value: &str) -> String {
120 if is_sensitive_key(key) && !value.is_empty() {
121 "***REDACTED***".to_string()
122 } else {
123 value.to_string()
124 }
125}
126
127impl fmt::Display for Env {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 for (i, (k, v)) in self.pairs.iter().enumerate() {
130 if i > 0 {
131 f.write_str(" ")?;
132 }
133 write!(f, "{}={}", k, masked_value(k, v))?;
134 }
135 Ok(())
136 }
137}
138
139impl From<HashMap<String, String>> for Env {
140 fn from(map: HashMap<String, String>) -> Self {
141 Self {
142 pairs: map.into_iter().collect(),
143 }
144 }
145}
146
147impl From<Vec<(String, String)>> for Env {
148 fn from(pairs: Vec<(String, String)>) -> Self {
149 Self { pairs }
150 }
151}
152
153impl FromIterator<(String, String)> for Env {
154 fn from_iter<T: IntoIterator<Item = (String, String)>>(iter: T) -> Self {
155 Self {
156 pairs: iter.into_iter().collect(),
157 }
158 }
159}
Added lib/gitbaby/src/command/error.rs lib/gitbaby/src/command/error.rs +187 −0 View lib/gitbaby/src/command/error.rs
@@ -0,0 +1,187 @@
1use std::fmt;
2use std::path::PathBuf;
3
4#[derive(Debug)]
5pub enum CmdError {
6 Spawn {
7 prog: String,
8 source: std::io::Error,
9 },
10 Io {
11 path: PathBuf,
12 source: std::io::Error,
13 },
14 NonZeroExit {
15 prog: String,
16 args: Vec<String>,
17 code: i32,
18 stderr: String,
19 },
20 Timeout {
21 prog: String,
22 timeout_ms: u64,
23 },
24 InvalidArgs(String),
25 Cancelled,
26 Pipe(String),
27 PayloadTooLarge {
28 cap_bytes: u64,
29 actual_bytes: u64,
30 },
31 Custom(String),
32}
33
34impl fmt::Display for CmdError {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 match self {
37 Self::Spawn { prog, source } => {
38 write!(f, "failed to spawn `{}`: {}", prog, source)
39 }
40 Self::Io { path, source } => {
41 write!(f, "io error at `{}`: {}", path.display(), source)
42 }
43 Self::NonZeroExit {
44 prog,
45 args,
46 code,
47 stderr,
48 } => {
49 let argv_summary = redact_argv(prog, args);
50 write!(
51 f,
52 "`{} {}` exited with code {}: {}",
53 prog,
54 argv_summary,
55 code,
56 stderr.trim()
57 )
58 }
59 Self::Timeout { prog, timeout_ms } => {
60 write!(f, "`{}` timed out after {}ms", prog, timeout_ms)
61 }
62 Self::InvalidArgs(msg) => write!(f, "invalid args: {}", msg),
63 Self::Cancelled => write!(f, "command was cancelled"),
64 Self::Pipe(msg) => write!(f, "pipe error: {}", msg),
65 Self::PayloadTooLarge {
66 cap_bytes,
67 actual_bytes,
68 } => write!(
69 f,
70 "output exceeds cap: {} bytes > {} bytes cap",
71 actual_bytes, cap_bytes
72 ),
73 Self::Custom(msg) => write!(f, "{}", msg),
74 }
75 }
76}
77
78impl std::error::Error for CmdError {
79 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
80 match self {
81 Self::Spawn { source, .. } | Self::Io { source, .. } => Some(source),
82 _ => None,
83 }
84 }
85}
86
87impl From<std::io::Error> for CmdError {
88 fn from(source: std::io::Error) -> Self {
89 Self::Io {
90 path: PathBuf::new(),
91 source,
92 }
93 }
94}
95
96pub type CmdResult<T> = std::result::Result<T, CmdError>;
97
98const REDACTED: &str = "***REDACTED***";
99
100fn redact_argv(prog: &str, args: &[String]) -> String {
101 let mut out = String::new();
102 out.push_str(prog);
103 let mut redact_next = false;
104 for arg in args {
105 out.push(' ');
106 if redact_next {
107 out.push_str(REDACTED);
108 redact_next = false;
109 continue;
110 }
111 // Flags whose secret value is the *next* argv element (e.g.
112 // `git -c http.extraheader=...`) must redact that following element.
113 if SEPARATE_VALUE_FLAGS.iter().any(|flag| *flag == arg) {
114 out.push_str(arg);
115 redact_next = true;
116 continue;
117 }
118 if SENSITIVE_FLAGS
119 .iter()
120 .any(|flag| arg == *flag || arg.starts_with(flag))
121 {
122 let label = if arg.contains('=') {
123 arg.split_once('=').map(|(k, _)| k).unwrap_or(arg)
124 } else {
125 arg.as_str()
126 };
127 out.push_str(label);
128 out.push('=');
129 out.push_str(REDACTED);
130 continue;
131 }
132 if URL_VALUE_FLAGS
133 .iter()
134 .any(|flag| arg == *flag || arg.starts_with(flag))
135 {
136 let label = if arg.contains('=') {
137 arg.split_once('=').map(|(k, _)| k).unwrap_or(arg)
138 } else {
139 arg.as_str()
140 };
141 out.push_str(label);
142 out.push_str(" <value>");
143 continue;
144 }
145 if looks_like_url(arg) {
146 out.push_str(REDACTED);
147 continue;
148 }
149 out.push_str(arg);
150 }
151 out
152}
153
154fn looks_like_url(s: &str) -> bool {
155 if s.starts_with("http://")
156 || s.starts_with("https://")
157 || s.starts_with("ssh://")
158 || s.starts_with("git://")
159 || s.starts_with("file://")
160 {
161 true
162 } else {
163 s.contains("://") && s.contains('@')
164 }
165}
166
167static SEPARATE_VALUE_FLAGS: &[&str] = &["-c"];
168
169static SENSITIVE_FLAGS: &[&str] = &[
170 "--upload-pack=",
171 "--receive-pack=",
172 "--config=",
173 "--config-env=",
174 "-S",
175 "--gpg-sign=",
176 "--key-id=",
177 "--ssh-command=",
178 "--askpass=",
179];
180
181static URL_VALUE_FLAGS: &[&str] = &[
182 "remote.add",
183 "remote set-url",
184 "remote prune",
185 "--exec=",
186 "--uploadarchive=",
187];
Added lib/gitbaby/src/command/mod.rs lib/gitbaby/src/command/mod.rs +5 −0 View lib/gitbaby/src/command/mod.rs
@@ -0,0 +1,5 @@
1pub mod cmd;
2pub mod context;
3pub mod env;
4pub mod error;
5pub mod pipe;
Added lib/gitbaby/src/command/pipe.rs lib/gitbaby/src/command/pipe.rs +128 −0 View lib/gitbaby/src/command/pipe.rs
@@ -0,0 +1,128 @@
1use crate::command::cmd::Cmd;
2use crate::command::context::GitPipeContext;
3use std::fmt;
4use std::path::PathBuf;
5
6#[derive(Clone)]
7pub struct Pipe {
8 pub name: String,
9 pub cmds: Vec<Cmd>,
10 pub dir: PathBuf,
11 pub timeout: Option<u64>,
12 pub cancelled: bool,
13}
14
15impl Pipe {
16 pub fn new(name: impl Into<String>, dir: PathBuf) -> Self {
17 Self {
18 name: name.into(),
19 cmds: Vec::new(),
20 dir,
21 timeout: None,
22 cancelled: false,
23 }
24 }
25
26 /// Whether new subprocesses may still be spawned on this pipe.
27 /// Once `cancel_pipe()` has been called this returns `false` until `reset()`.
28 pub fn spawn_allowed(&self) -> bool {
29 !self.cancelled
30 }
31
32 /// Re-arm a cancelled pipe so new commands may be scheduled again.
33 /// Pre-existing children that were killed during `cancel_pipe` are kept in
34 /// `cmds` for audit but must not be reused.
35 pub fn reset(&mut self) {
36 self.cancelled = false;
37 }
38
39 pub fn push(mut self, cmd: Cmd) -> Self {
40 self.cmds.push(cmd);
41 self
42 }
43
44 pub fn extend<I: IntoIterator<Item = Cmd>>(mut self, cmds: I) -> Self {
45 self.cmds.extend(cmds);
46 self
47 }
48
49 /// Set the per-command timeout in **seconds** (matching `Cmd::timeout`,
50 /// which is interpreted via `Duration::from_secs`).
51 pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
52 self.timeout = Some(timeout_secs);
53 self
54 }
55
56 pub fn with_dir(mut self, dir: PathBuf) -> Self {
57 self.dir = dir;
58 self
59 }
60
61 pub fn len(&self) -> usize {
62 self.cmds.len()
63 }
64
65 pub fn is_empty(&self) -> bool {
66 self.cmds.is_empty()
67 }
68
69 pub fn is_cancelled(&self) -> bool {
70 self.cancelled
71 }
72
73 pub fn first(&self) -> Option<&Cmd> {
74 self.cmds.first()
75 }
76
77 pub fn last(&self) -> Option<&Cmd> {
78 self.cmds.last()
79 }
80}
81
82impl fmt::Debug for Pipe {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 f.debug_struct("Pipe")
85 .field("name", &self.name)
86 .field("cmds", &self.cmds.len())
87 .field("dir", &self.dir)
88 .field("timeout", &self.timeout)
89 .field("cancelled", &self.cancelled)
90 .finish()
91 }
92}
93
94impl fmt::Display for Pipe {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 if self.cmds.is_empty() {
97 return write!(f, "{}:<empty>", self.name);
98 }
99 let parts: Vec<String> = self.cmds.iter().map(|c| format!("{c}")).collect();
100 write!(f, "{}: {}", self.name, parts.join(" | "))
101 }
102}
103
104impl GitPipeContext for Pipe {
105 fn cancel_pipe(&mut self) {
106 self.cancelled = true;
107 for cmd in &mut self.cmds {
108 if let Some(mut child) = cmd.cmd.take() {
109 // The child handle is handed to the reaper task below; mark the
110 // command as finished so it can never be spawned again, even
111 // after a later `reset()` re-arms the pipe.
112 cmd.finished = true;
113 let _ = child.start_kill();
114 tokio::spawn(async move {
115 let _ = child.wait().await;
116 });
117 }
118 }
119 }
120
121 fn current_index(&self) -> Option<usize> {
122 if self.cancelled || self.cmds.is_empty() {
123 None
124 } else {
125 Some(self.cmds.len().saturating_sub(1))
126 }
127 }
128}
Added lib/gitbaby/src/commit/helper.rs lib/gitbaby/src/commit/helper.rs +636 −0 View lib/gitbaby/src/commit/helper.rs
@@ -0,0 +1,636 @@
1use std::path::{Path, PathBuf};
2
3use crate::command::cmd::Cmd;
4use crate::command::env::Env;
5use crate::command::error::CmdError;
6use crate::commit::types::{
7 CommitInfo, CommitShortStat, CommitStats, ListCommitsOptions, Signature, TreeEntry,
8 TreeEntryMode,
9};
10use crate::error::BabyError;
11use crate::share;
12
13pub 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";
14pub const CALLER: &str = "gitbaby::commit";
15
16pub struct StderrCtx<'a> {
17 pub revision: &'a str,
18 pub path: Option<&'a Path>,
19}
20
21pub fn validate_revision(rev: &str) -> Result<(), BabyError> {
22 share::validate_revision(rev)
23}
24
25pub fn validate_path_local_no_escape(path: &Path) -> Result<(), BabyError> {
26 share::validate_local_no_escape(path)
27}
28
29pub fn validate_list_commits_options(opts: &ListCommitsOptions) -> Result<(), BabyError> {
30 validate_revision(&opts.revision)?;
31 if let Some(p) = opts.path.as_deref() {
32 validate_path_local_no_escape(p)?;
33 }
34 Ok(())
35}
36
37pub fn build_env(alternates: &[PathBuf]) -> Env {
38 share::build_env_safe(alternates)
39}
40
41pub fn build_get_commit_cmd(dir: &Path, env: Env, rev: &str) -> Cmd {
42 let args = vec!["cat-file".to_string(), "-p".to_string(), rev.to_string()];
43 share::git_cmd(CALLER, dir, env, None, args)
44}
45
46pub fn build_rev_parse_cmd(dir: &Path, env: Env, rev: &str) -> Cmd {
47 let args = vec![
48 "rev-parse".to_string(),
49 "--verify".to_string(),
50 "--end-of-options".to_string(),
51 format!("{}^{{commit}}", rev),
52 ];
53 share::git_cmd(CALLER, dir, env, None, args)
54}
55
56pub fn build_is_ancestor_cmd(dir: &Path, env: Env, ancestor: &str, descendant: &str) -> Cmd {
57 let args = vec![
58 "merge-base".to_string(),
59 "--is-ancestor".to_string(),
60 ancestor.to_string(),
61 descendant.to_string(),
62 ];
63 share::git_cmd(CALLER, dir, env, None, args)
64}
65
66pub fn build_list_commits_cmd(dir: &Path, env: Env, opts: &ListCommitsOptions) -> Cmd {
67 let mut args: Vec<String> = Vec::with_capacity(8);
68 args.push("-c".to_string());
69 args.push("core.quotepath=off".to_string());
70 args.push("log".to_string());
71 args.push("-z".to_string());
72 args.push(format!("--format={}", LOG_FORMAT));
73 if let Some(n) = opts.max_count {
74 args.push(format!("-n{}", n));
75 }
76 if let Some(n) = opts.skip {
77 args.push(format!("--skip={}", n));
78 }
79 if let Some(s) = opts.since.as_deref() {
80 args.push(format!("--since={}", s));
81 }
82 if let Some(u) = opts.until.as_deref() {
83 args.push(format!("--until={}", u));
84 }
85 if opts.first_parent {
86 args.push("--first-parent".to_string());
87 }
88 if !opts.revision.is_empty() {
89 args.push(opts.revision.clone());
90 }
91 if let Some(p) = opts.path.as_deref() {
92 args.push("--".to_string());
93 args.push(p.to_string_lossy().into_owned());
94 }
95 share::git_cmd(CALLER, dir, env, None, args)
96}
97
98pub fn build_count_commits_cmd(dir: &Path, env: Env, opts: &ListCommitsOptions) -> Cmd {
99 let mut args: Vec<String> = Vec::with_capacity(8);
100 args.push("rev-list".to_string());
101 args.push("--count".to_string());
102 if let Some(s) = opts.since.as_deref() {
103 args.push(format!("--since={}", s));
104 }
105 if let Some(u) = opts.until.as_deref() {
106 args.push(format!("--until={}", u));
107 }
108 if opts.first_parent {
109 args.push("--first-parent".to_string());
110 }
111 if !opts.revision.is_empty() {
112 args.push(opts.revision.clone());
113 }
114 if let Some(p) = opts.path.as_deref() {
115 args.push("--".to_string());
116 args.push(p.to_string_lossy().into_owned());
117 }
118 share::git_cmd(CALLER, dir, env, None, args)
119}
120
121pub fn build_commit_stats_cmd(dir: &Path, env: Env, rev: &str, path: Option<&Path>) -> Cmd {
122 let mut args: Vec<String> = Vec::with_capacity(4);
123 args.push("diff".to_string());
124 args.push("--shortstat".to_string());
125 args.push(format!("{}^!", rev));
126 if let Some(p) = path {
127 args.push("--".to_string());
128 args.push(p.to_string_lossy().into_owned());
129 }
130 share::git_cmd(CALLER, dir, env, None, args)
131}
132
133pub fn build_commit_short_stats_cmd(dir: &Path, env: Env, rev: &str) -> Cmd {
134 let args = vec![
135 "-c".to_string(),
136 "core.quotepath=off".to_string(),
137 "log".to_string(),
138 "--shortstat".to_string(),
139 "--format=__GDCOMMIT__%H%x00%an%x00%ae%x00".to_string(),
140 rev.to_string(),
141 ];
142 share::git_cmd(CALLER, dir, env, None, args)
143}
144
145pub fn build_tree_entries_cmd(dir: &Path, env: Env, rev: &str, path: &Path) -> Cmd {
146 let mut args = vec!["ls-tree".to_string(), "-l".to_string(), rev.to_string()];
147 let path_str = path.to_string_lossy();
148 if !path_str.is_empty() && path_str != "." {
149 args.push("--".to_string());
150 args.push(format!("{}/", path_str.trim_end_matches('/')));
151 }
152 share::git_cmd(CALLER, dir, env, None, args)
153}
154
155pub fn build_last_commit_for_path_cmd(dir: &Path, env: Env, rev: &str, path: &Path) -> Cmd {
156 let args = vec![
157 "-c".to_string(),
158 "core.quotepath=off".to_string(),
159 "log".to_string(),
160 "-1".to_string(),
161 format!("--format={}", LOG_FORMAT),
162 rev.to_string(),
163 "--".to_string(),
164 path.to_string_lossy().into_owned(),
165 ];
166 share::git_cmd(CALLER, dir, env, None, args)
167}
168
169pub fn classify_stderr(stderr: &str, ctx: &StderrCtx<'_>) -> BabyError {
170 let trimmed = stderr.trim();
171 if let Some(rest) = trimmed.strip_prefix("fatal: ambiguous argument '")
172 && let Some(needle) =
173 rest.strip_suffix("': unknown revision or path not in the working tree.")
174 {
175 return BabyError::RevisionNotFound(needle.to_string());
176 }
177 if trimmed.contains("Needed a single revision") {
178 return BabyError::RevisionNotFound(ctx.revision.to_string());
179 }
180 if let Some(rest) = trimmed.strip_prefix("fatal: Not a tree: ") {
181 return BabyError::NotATree {
182 revision: rest.trim().to_string(),
183 path: ctx.path.map_or(PathBuf::new(), |p| p.to_path_buf()),
184 };
185 }
186 if trimmed.contains("bad revision") || trimmed.contains("unknown revision") {
187 let mut iter = trimmed
188 .split_whitespace()
189 .filter(|w| !w.starts_with("fatal:"));
190 let rev = match iter.next() {
191 Some(w) => w.trim_matches('\'').to_string(),
192 None => String::new(),
193 };
194 if rev.is_empty() {
195 return BabyError::RevisionNotFound(ctx.revision.to_string());
196 }
197 return BabyError::RevisionNotFound(rev);
198 }
199 if let Some(rest) = trimmed.strip_prefix("fatal: not a tree object ") {
200 return BabyError::NotATree {
201 revision: rest.trim().to_string(),
202 path: ctx.path.map_or(PathBuf::new(), |p| p.to_path_buf()),
203 };
204 }
205 if let Some(rest) = trimmed.strip_prefix("fatal: not a commit: ") {
206 return BabyError::RevisionInvalid(rest.trim().to_string());
207 }
208 BabyError::Spawn {
209 prog: "git".to_string(),
210 source: std::io::Error::other("git non-zero exit"),
211 stderr: trimmed.to_string(),
212 }
213}
214
215pub fn classify_cmd_error(err: CmdError, ctx: &StderrCtx<'_>) -> BabyError {
216 match err {
217 CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, ctx),
218 CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
219 other => BabyError::from(other),
220 }
221}
222
223pub fn facade_error<E: std::error::Error + Send + Sync + 'static>(e: E) -> BabyError {
224 share::facade_error(e)
225}
226
227pub fn parse_signature_line(line: &str) -> Result<Signature, BabyError> {
228 let lt_idx = line.rfind(" <").ok_or_else(|| BabyError::CommitParse {
229 line_number: 0,
230 message: format!("missing `<` in signature: {}", line),
231 })?;
232 let gt_idx = line.rfind('>').ok_or_else(|| BabyError::CommitParse {
233 line_number: 0,
234 message: format!("missing `>` in signature: {}", line),
235 })?;
236 if gt_idx <= lt_idx {
237 return Err(BabyError::CommitParse {
238 line_number: 0,
239 message: format!("malformed signature delimiters: {}", line),
240 });
241 }
242 let name_raw = line[..lt_idx].trim().to_string();
243 let name = if let Some(stripped) = name_raw.strip_prefix('"').and_then(|s| s.strip_suffix('"'))
244 {
245 unescape_quoted_name(stripped)
246 } else {
247 name_raw
248 };
249 let email = line[lt_idx + 2..gt_idx].trim().to_string();
250 let tail = line[gt_idx + 1..].trim();
251 let mut parts = tail.split_whitespace();
252 let ts_str = parts.next().ok_or_else(|| BabyError::CommitParse {
253 line_number: 0,
254 message: format!("missing timestamp in signature: {}", line),
255 })?;
256 let tz_str = parts.next().ok_or_else(|| BabyError::CommitParse {
257 line_number: 0,
258 message: format!("missing timezone in signature: {}", line),
259 })?;
260 let unix: i64 = ts_str
261 .parse()
262 .map_err(|_| BabyError::InvalidTimestamp(ts_str.to_string()))?;
263 let offset = parse_offset(tz_str)?;
264 let time = time::OffsetDateTime::from_unix_timestamp(unix)
265 .map_err(|_| BabyError::InvalidTimestamp(ts_str.to_string()))?
266 .to_offset(offset);
267 Ok(Signature { name, email, time })
268}
269
270fn unescape_quoted_name(s: &str) -> String {
271 let mut out = String::with_capacity(s.len());
272 let mut chars = s.chars();
273 while let Some(c) = chars.next() {
274 if c == '\\' {
275 if let Some(next) = chars.next() {
276 match next {
277 'n' => out.push('\n'),
278 't' => out.push('\t'),
279 '\\' => out.push('\\'),
280 '"' => out.push('"'),
281 other => {
282 out.push('\\');
283 out.push(other);
284 }
285 }
286 } else {
287 out.push('\\');
288 }
289 } else {
290 out.push(c);
291 }
292 }
293 out
294}
295
296fn parse_offset(s: &str) -> Result<time::UtcOffset, BabyError> {
297 let bytes = s.as_bytes();
298 if bytes.len() != 5 || (bytes[0] != b'+' && bytes[0] != b'-') {
299 return Err(BabyError::InvalidTimestamp(format!("offset {}", s)));
300 }
301 let sign = if bytes[0] == b'-' { -1 } else { 1 };
302 let hours: i32 = std::str::from_utf8(&bytes[1..3])
303 .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))?
304 .parse()
305 .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))?;
306 let mins: i32 = std::str::from_utf8(&bytes[3..5])
307 .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))?
308 .parse()
309 .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))?;
310 let total_seconds = sign * (hours * 3600 + mins * 60);
311 time::UtcOffset::from_whole_seconds(total_seconds)
312 .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))
313}
314
315pub fn parse_commit_porcelain(id: gix::ObjectId, bytes: &[u8]) -> Result<CommitInfo, BabyError> {
316 let text = std::str::from_utf8(bytes).map_err(|source| BabyError::CommitParse {
317 line_number: 0,
318 message: format!("commit porcelain is not utf-8: {}", source),
319 })?;
320 let mut tree_id: Option<gix::ObjectId> = None;
321 let mut parents: Vec<gix::ObjectId> = Vec::new();
322 let mut author: Option<Signature> = None;
323 let mut committer: Option<Signature> = None;
324 let mut gpg_signature: Option<String> = None;
325 let mut message_lines: Vec<String> = Vec::new();
326 let mut in_message = false;
327 for (idx, line) in text.lines().enumerate() {
328 let line_no = idx as u64 + 1;
329 if in_message {
330 message_lines.push(line.to_string());
331 continue;
332 }
333 if line.is_empty() {
334 in_message = true;
335 continue;
336 }
337 if let Some(rest) = line.strip_prefix("tree ") {
338 tree_id = Some(parse_object_id_field(rest, line_no, "tree")?);
339 continue;
340 }
341 if let Some(rest) = line.strip_prefix("parent ") {
342 parents.push(parse_object_id_field(rest, line_no, "parent")?);
343 continue;
344 }
345 if let Some(rest) = line.strip_prefix("author ") {
346 author = Some(
347 parse_signature_line(rest).map_err(|_| BabyError::CommitParse {
348 line_number: line_no,
349 message: format!("bad author: {}", rest),
350 })?,
351 );
352 continue;
353 }
354 if let Some(rest) = line.strip_prefix("committer ") {
355 committer = Some(
356 parse_signature_line(rest).map_err(|_| BabyError::CommitParse {
357 line_number: line_no,
358 message: format!("bad committer: {}", rest),
359 })?,
360 );
361 continue;
362 }
363 if let Some(rest) = line.strip_prefix("gpgsig ") {
364 gpg_signature = Some(rest.trim_start().trim_end_matches('\r').to_string());
365 continue;
366 }
367 if line.starts_with(' ') && gpg_signature.is_some() {
368 if let Some(sig) = gpg_signature.as_mut() {
369 sig.push('\n');
370 sig.push_str(line.trim_start());
371 }
372 continue;
373 }
374 return Err(BabyError::CommitParse {
375 line_number: line_no,
376 message: format!("unexpected header line: {}", line),
377 });
378 }
379 let tree_id = tree_id.ok_or_else(|| BabyError::CommitParse {
380 line_number: 0,
381 message: "commit porcelain missing tree".into(),
382 })?;
383 let author = author.ok_or_else(|| BabyError::CommitParse {
384 line_number: 0,
385 message: "commit porcelain missing author".into(),
386 })?;
387 let committer = committer.ok_or_else(|| BabyError::CommitParse {
388 line_number: 0,
389 message: "commit porcelain missing committer".into(),
390 })?;
391 let message = if message_lines.is_empty() {
392 String::new()
393 } else {
394 let trimmed_start = message_lines
395 .iter()
396 .position(|l| !l.is_empty())
397 .unwrap_or(message_lines.len());
398 let mut lines = message_lines[trimmed_start..].to_vec();
399 while lines.last().is_some_and(|l| l.is_empty()) {
400 lines.pop();
401 }
402 lines.join("\n")
403 };
404 Ok(CommitInfo {
405 id,
406 tree_id,
407 parents,
408 author,
409 committer,
410 message,
411 gpg_signature,
412 })
413}
414
415fn parse_object_id_field(s: &str, line_no: u64, field: &str) -> Result<gix::ObjectId, BabyError> {
416 let hex = s.trim();
417 gix::ObjectId::from_hex(hex.as_bytes()).map_err(|source| BabyError::CommitParse {
418 line_number: line_no,
419 message: format!("bad {} oid `{}`: {}", field, hex, source),
420 })
421}
422
423pub fn parse_log_porcelain(bytes: &[u8]) -> Result<Vec<CommitInfo>, BabyError> {
424 let mut chunks: Vec<&[u8]> = bytes.split(|b| *b == 0).collect();
425 if chunks.last().is_some_and(|c| c.is_empty()) {
426 chunks.pop();
427 }
428 fn parse_field<'a>(f: &'a [u8], what: &str) -> Result<&'a str, BabyError> {
429 std::str::from_utf8(f).map_err(|source| BabyError::CommitParse {
430 line_number: 0,
431 message: format!("{} not utf-8: {}", what, source),
432 })
433 }
434 fn parse_unix(s: &str) -> Result<time::OffsetDateTime, BabyError> {
435 let unix: i64 = s
436 .parse()
437 .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))?;
438 time::OffsetDateTime::from_unix_timestamp(unix)
439 .map(|t| t.to_offset(time::UtcOffset::UTC))
440 .map_err(|_| BabyError::InvalidTimestamp(s.to_string()))
441 }
442 let mut commits = Vec::with_capacity(chunks.len() / 11);
443 for fields in chunks.as_chunks::<11>().0 {
444 let id = gix::ObjectId::from_hex(fields[0]).map_err(|source| BabyError::CommitParse {
445 line_number: 0,
446 message: format!("bad id: {}", source),
447 })?;
448 let tree_id =
449 gix::ObjectId::from_hex(fields[1]).map_err(|source| BabyError::CommitParse {
450 line_number: 0,
451 message: format!("bad tree: {}", source),
452 })?;
453 let mut parents = Vec::new();
454 if !fields[2].is_empty() {
455 for hex in fields[2].split(|b| *b == b' ') {
456 if hex.is_empty() {
457 continue;
458 }
459 let p = gix::ObjectId::from_hex(hex).map_err(|source| BabyError::CommitParse {
460 line_number: 0,
461 message: format!("bad parent: {}", source),
462 })?;
463 parents.push(p);
464 }
465 }
466 let author = Signature {
467 name: parse_field(fields[3], "author name")?.to_string(),
468 email: parse_field(fields[4], "author email")?.to_string(),
469 time: parse_unix(parse_field(fields[5], "author time")?)?,
470 };
471 let committer = Signature {
472 name: parse_field(fields[6], "committer name")?.to_string(),
473 email: parse_field(fields[7], "committer email")?.to_string(),
474 time: parse_unix(parse_field(fields[8], "committer time")?)?,
475 };
476 let subject = parse_field(fields[9], "subject")?.to_string();
477 let body = parse_field(fields[10], "body")?
478 .trim_end_matches('\n')
479 .to_string();
480 let message = if body.is_empty() {
481 subject
482 } else {
483 format!("{}\n\n{}", subject, body)
484 };
485 commits.push(CommitInfo {
486 id,
487 tree_id,
488 parents,
489 author,
490 committer,
491 message,
492 gpg_signature: None,
493 });
494 }
495 Ok(commits)
496}
497
498pub fn parse_tree_entries(bytes: &[u8]) -> Result<Vec<TreeEntry>, BabyError> {
499 let mut out = Vec::new();
500 for (idx, line) in bytes.split(|b| *b == b'\n').enumerate() {
501 if line.is_empty() {
502 continue;
503 }
504 let line_str = std::str::from_utf8(line).map_err(|source| BabyError::CommitParse {
505 line_number: idx as u64 + 1,
506 message: format!("tree entry not utf-8: {}", source),
507 })?;
508 let mut parts = line_str.splitn(3, ' ');
509 let mode_str = parts.next().ok_or_else(|| BabyError::CommitParse {
510 line_number: idx as u64 + 1,
511 message: format!("tree entry missing mode: {}", line_str),
512 })?;
513 let mode =
514 TreeEntryMode::from_octal_str(mode_str).ok_or_else(|| BabyError::CommitParse {
515 line_number: idx as u64 + 1,
516 message: format!("unknown tree mode: {}", mode_str),
517 })?;
518 parts.next();
519 let tail = parts.next().ok_or_else(|| BabyError::CommitParse {
520 line_number: idx as u64 + 1,
521 message: format!("tree entry missing tail: {}", line_str),
522 })?;
523 let (head, name_field) = tail
524 .split_once('\t')
525 .ok_or_else(|| BabyError::CommitParse {
526 line_number: idx as u64 + 1,
527 message: format!("tree entry missing name: {}", line_str),
528 })?;
529 let oid_hex = head.split_whitespace().next().unwrap_or("");
530 let oid = gix::ObjectId::from_hex(oid_hex.as_bytes()).map_err(|source| {
531 BabyError::CommitParse {
532 line_number: idx as u64 + 1,
533 message: format!("bad tree oid `{}`: {}", oid_hex, source),
534 }
535 })?;
536 let size = head
537 .split_whitespace()
538 .nth(1)
539 .and_then(|s| s.parse::<u64>().ok());
540 out.push(TreeEntry {
541 oid,
542 name: name_field.to_string(),
543 mode,
544 size,
545 });
546 }
547 Ok(out)
548}
549
550pub fn parse_shortstat(bytes: &[u8]) -> Result<CommitStats, BabyError> {
551 let text = std::str::from_utf8(bytes).map_err(|source| BabyError::CommitParse {
552 line_number: 0,
553 message: format!("shortstat not utf-8: {}", source),
554 })?;
555 let mut stats = CommitStats::default();
556 for part in text.split_whitespace() {
557 if let Some(rest) = part.strip_suffix(')') {
558 if rest.contains("(+)") {
559 let n: u64 = rest.trim_end_matches("(+)").parse().map_err(|source| {
560 BabyError::CommitParse {
561 line_number: 0,
562 message: format!("bad additions number `{}`: {}", part, source),
563 }
564 })?;
565 stats.additions = n;
566 continue;
567 }
568 if rest.contains("(-)") {
569 let n: u64 = rest.trim_end_matches("(-)").parse().map_err(|source| {
570 BabyError::CommitParse {
571 line_number: 0,
572 message: format!("bad deletions number `{}`: {}", part, source),
573 }
574 })?;
575 stats.deletions = n;
576 continue;
577 }
578 }
579 }
580 Ok(stats)
581}
582
583pub fn parse_commit_short_stats(bytes: &[u8]) -> Result<Vec<CommitShortStat>, BabyError> {
584 let text = std::str::from_utf8(bytes).map_err(|source| BabyError::CommitParse {
585 line_number: 0,
586 message: format!("commit shortstat stream not utf-8: {}", source),
587 })?;
588 let mut out = Vec::new();
589 for record in text.split("__GDCOMMIT__").skip(1) {
590 let mut fields = record.splitn(4, '\0');
591 let Some(oid_hex) = fields.next() else {
592 continue;
593 };
594 let Some(name) = fields.next() else { continue };
595 let Some(email) = fields.next() else { continue };
596 let rest = fields.next().unwrap_or("");
597 let oid = gix::ObjectId::from_hex(oid_hex.as_bytes()).map_err(|source| {
598 BabyError::CommitParse {
599 line_number: 0,
600 message: format!("bad commit oid `{}`: {}", oid_hex, source),
601 }
602 })?;
603 let (additions, deletions) = shortstat_numbers(rest);
604 out.push(CommitShortStat {
605 oid,
606 author_name: name.to_string(),
607 author_email: email.to_string(),
608 additions,
609 deletions,
610 });
611 }
612 Ok(out)
613}
614
615fn shortstat_numbers(rest: &str) -> (u64, u64) {
616 let mut additions = 0u64;
617 let mut deletions = 0u64;
618 let mut prev_num: Option<u64> = None;
619 for part in rest.split_whitespace() {
620 if let Ok(n) = part.trim_end_matches(',').parse::<u64>() {
621 prev_num = Some(n);
622 continue;
623 }
624 let word = part
625 .trim_end_matches(',')
626 .trim_end_matches(')')
627 .trim_end_matches("(+")
628 .trim_end_matches("(-");
629 if word == "insertion" || word == "insertions" {
630 additions = prev_num.unwrap_or(0);
631 } else if word == "deletion" || word == "deletions" {
632 deletions = prev_num.unwrap_or(0);
633 }
634 }
635 (additions, deletions)
636}
Added lib/gitbaby/src/commit/mod.rs lib/gitbaby/src/commit/mod.rs +8 −0 View lib/gitbaby/src/commit/mod.rs
@@ -0,0 +1,8 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{
6 CommitInfo, CommitShortStat, CommitStats, ListCommitsOptions, Signature, TreeEntry,
7 TreeEntryMode,
8};
Added lib/gitbaby/src/commit/types.rs lib/gitbaby/src/commit/types.rs +113 −0 View lib/gitbaby/src/commit/types.rs
@@ -0,0 +1,113 @@
1use std::fmt;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7pub enum TreeEntryMode {
8 Blob,
9 Exec,
10 Symlink,
11 Tree,
12 Submodule,
13 Commit,
14}
15
16impl TreeEntryMode {
17 pub fn from_octal_str(s: &str) -> Option<Self> {
18 let kind = u32::from_str_radix(s, 8).ok()?;
19 let file_kind = kind & 0o170000;
20 match file_kind {
21 0o040000 => return Some(Self::Tree),
22 0o160000 => return Some(Self::Commit),
23 _ => {}
24 }
25 match kind {
26 0o100644 => Some(Self::Blob),
27 0o100755 => Some(Self::Exec),
28 0o120000 => Some(Self::Symlink),
29 _ => None,
30 }
31 }
32}
33
34impl fmt::Display for TreeEntryMode {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 let s = match self {
37 Self::Blob => "blob",
38 Self::Exec => "exec",
39 Self::Symlink => "symlink",
40 Self::Tree => "tree",
41 Self::Submodule => "submodule",
42 Self::Commit => "commit",
43 };
44 f.write_str(s)
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct Signature {
50 pub name: String,
51 pub email: String,
52 pub time: time::OffsetDateTime,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct CommitInfo {
57 pub id: gix::ObjectId,
58 pub tree_id: gix::ObjectId,
59 pub parents: Vec<gix::ObjectId>,
60 pub author: Signature,
61 pub committer: Signature,
62 pub message: String,
63 pub gpg_signature: Option<String>,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
67pub struct CommitStats {
68 pub additions: u64,
69 pub deletions: u64,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct CommitShortStat {
74 pub oid: gix::ObjectId,
75 pub author_name: String,
76 pub author_email: String,
77 pub additions: u64,
78 pub deletions: u64,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct TreeEntry {
83 pub oid: gix::ObjectId,
84 pub name: String,
85 pub mode: TreeEntryMode,
86 pub size: Option<u64>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct ListCommitsOptions {
91 pub revision: String,
92 pub path: Option<PathBuf>,
93 pub max_count: Option<u32>,
94 #[serde(default)]
95 pub skip: Option<u32>,
96 pub since: Option<String>,
97 pub until: Option<String>,
98 pub first_parent: bool,
99}
100
101impl Default for ListCommitsOptions {
102 fn default() -> Self {
103 Self {
104 revision: "HEAD".to_string(),
105 path: None,
106 max_count: None,
107 skip: None,
108 since: None,
109 until: None,
110 first_parent: false,
111 }
112 }
113}
Added lib/gitbaby/src/commit/usecase.rs lib/gitbaby/src/commit/usecase.rs +413 −0 View lib/gitbaby/src/commit/usecase.rs
@@ -0,0 +1,413 @@
1use std::path::Path;
2
3use crate::GitBaby;
4use crate::commit::helper;
5use crate::commit::types::{
6 CommitInfo, CommitShortStat, CommitStats, ListCommitsOptions, TreeEntry,
7};
8use crate::error::BabyError;
9
10impl GitBaby {
11 pub async fn get_commit(&self, revision: &str) -> Result<CommitInfo, BabyError> {
12 helper::validate_revision(revision)?;
13 let id = self
14 .rev_parse(revision)
15 .await
16 .map_err(helper::facade_error)?;
17 let pool = crate::cache::try_global_cache();
18 let key = crate::cache::object_key(crate::cache::NS_COMMIT, &id);
19 if let Some(p) = &pool
20 && let Ok(Some(v)) = p.store().get(&key).await
21 && let Ok(info) = serde_json::from_slice(&v)
22 {
23 return Ok(info);
24 }
25 let mut cmd = self
26 .spawn_commit_cmd(revision, |dir, env| {
27 helper::build_get_commit_cmd(dir, env, revision)
28 })
29 .await?;
30 let ctx = helper::StderrCtx {
31 revision,
32 path: None,
33 };
34 let output = cmd
35 .run()
36 .await
37 .map_err(|e| helper::classify_cmd_error(e, &ctx))?;
38 if !output.status.success() {
39 return Err(helper::classify_stderr(
40 &String::from_utf8_lossy(&output.stderr),
41 &ctx,
42 ));
43 }
44 let info = helper::parse_commit_porcelain(id, &output.stdout)?;
45 if let Some(p) = &pool
46 && let Ok(v) = serde_json::to_vec(&info)
47 {
48 p.store().insert(key, v);
49 }
50 Ok(info)
51 }
52
53 pub async fn rev_parse(&self, revision: &str) -> Result<gix::ObjectId, BabyError> {
54 helper::validate_revision(revision)?;
55 let pool = crate::cache::try_global_cache();
56 let key = if pool.is_some() {
57 let dir = self
58 .facade
59 .git_repo_dir()
60 .await
61 .map_err(helper::facade_error)?;
62 let dir = std::fs::canonicalize(&dir)
63 .map_err(|source| BabyError::Io { path: dir, source })?;
64 Some(crate::cache::rev_key(&dir, revision))
65 } else {
66 None
67 };
68 if let (Some(p), Some(key)) = (&pool, &key)
69 && let Ok(Some(v)) = p.store().get(key).await
70 && let Ok(hex) = String::from_utf8(v)
71 && let Ok(oid) = gix::ObjectId::from_hex(hex.trim().as_bytes())
72 {
73 return Ok(oid);
74 }
75 let mut cmd = self
76 .spawn_commit_cmd(revision, |dir, env| {
77 helper::build_rev_parse_cmd(dir, env, revision)
78 })
79 .await?;
80 let ctx = helper::StderrCtx {
81 revision,
82 path: None,
83 };
84 let output = cmd
85 .run()
86 .await
87 .map_err(|e| helper::classify_cmd_error(e, &ctx))?;
88 if !output.status.success() {
89 return Err(helper::classify_stderr(
90 &String::from_utf8_lossy(&output.stderr),
91 &ctx,
92 ));
93 }
94 let text = String::from_utf8_lossy(&output.stdout);
95 let hex = text.trim();
96 let oid = gix::ObjectId::from_hex(hex.as_bytes()).map_err(|source| {
97 BabyError::Custom(format!(
98 "rev-parse returned invalid oid `{}`: {}",
99 hex, source
100 ))
101 })?;
102 if let (Some(p), Some(key)) = (&pool, &key) {
103 p.store().insert(key.clone(), hex.as_bytes().to_vec());
104 }
105 Ok(oid)
106 }
107
108 pub async fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool, BabyError> {
109 helper::validate_revision(ancestor)?;
110 helper::validate_revision(descendant)?;
111 let mut cmd = self
112 .spawn_commit_cmd(descendant, |dir, env| {
113 helper::build_is_ancestor_cmd(dir, env, ancestor, descendant)
114 })
115 .await?;
116 let ctx = helper::StderrCtx {
117 revision: descendant,
118 path: None,
119 };
120 let output = match cmd.run().await {
121 Ok(o) => o,
122 Err(CmdError::NonZeroExit { stderr, .. }) => {
123 if stderr.trim().is_empty() {
124 return Ok(false);
125 }
126 return Err(helper::classify_stderr(&stderr, &ctx));
127 }
128 Err(e) => return Err(helper::classify_cmd_error(e, &ctx)),
129 };
130 if output.status.success() {
131 Ok(true)
132 } else {
133 Ok(false)
134 }
135 }
136
137 pub async fn list_commits(
138 &self,
139 opts: ListCommitsOptions,
140 ) -> Result<Vec<CommitInfo>, BabyError> {
141 helper::validate_list_commits_options(&opts)?;
142 let rev = opts.revision.clone();
143 let mut cmd = self
144 .spawn_commit_cmd(&rev, |dir, env| {
145 helper::build_list_commits_cmd(dir, env, &opts)
146 })
147 .await?;
148 let ctx = helper::StderrCtx {
149 revision: &rev,
150 path: opts.path.as_deref(),
151 };
152 let output = cmd
153 .run()
154 .await
155 .map_err(|e| helper::classify_cmd_error(e, &ctx))?;
156 if !output.status.success() {
157 return Err(helper::classify_stderr(
158 &String::from_utf8_lossy(&output.stderr),
159 &ctx,
160 ));
161 }
162 helper::parse_log_porcelain(&output.stdout)
163 }
164
165 pub async fn count_commits(&self, opts: ListCommitsOptions) -> Result<u64, BabyError> {
166 helper::validate_list_commits_options(&opts)?;
167 let rev = opts.revision.clone();
168 let mut cmd = self
169 .spawn_commit_cmd(&rev, |dir, env| {
170 helper::build_count_commits_cmd(dir, env, &opts)
171 })
172 .await?;
173 let ctx = helper::StderrCtx {
174 revision: &rev,
175 path: opts.path.as_deref(),
176 };
177 let output = cmd
178 .run()
179 .await
180 .map_err(|e| helper::classify_cmd_error(e, &ctx))?;
181 if !output.status.success() {
182 return Err(helper::classify_stderr(
183 &String::from_utf8_lossy(&output.stderr),
184 &ctx,
185 ));
186 }
187 let s = String::from_utf8_lossy(&output.stdout);
188 s.trim().parse::<u64>().map_err(|_| BabyError::CommitParse {
189 line_number: 0,
190 message: format!("rev-list --count returned `{}`", s.trim()),
191 })
192 }
193
194 pub async fn commit_stats(
195 &self,
196 revision: &str,
197 path: Option<&Path>,
198 ) -> Result<CommitStats, BabyError> {
199 helper::validate_revision(revision)?;
200 if let Some(p) = path {
201 helper::validate_path_local_no_escape(p)?;
202 }
203 let mut cmd = self
204 .spawn_commit_cmd(revision, |dir, env| {
205 helper::build_commit_stats_cmd(dir, env, revision, path)
206 })
207 .await?;
208 let ctx = helper::StderrCtx { revision, path };
209 let output = cmd
210 .run()
211 .await
212 .map_err(|e| helper::classify_cmd_error(e, &ctx))?;
213 if !output.status.success() {
214 return Err(helper::classify_stderr(
215 &String::from_utf8_lossy(&output.stderr),
216 &ctx,
217 ));
218 }
219 helper::parse_shortstat(&output.stdout)
220 }
221
222 pub async fn commit_short_stats(
223 &self,
224 revision: &str,
225 ) -> Result<Vec<CommitShortStat>, BabyError> {
226 helper::validate_revision(revision)?;
227 let mut cmd = self
228 .spawn_commit_cmd(revision, |dir, env| {
229 helper::build_commit_short_stats_cmd(dir, env, revision)
230 })
231 .await?;
232 let ctx = helper::StderrCtx {
233 revision,
234 path: None,
235 };
236 let output = cmd
237 .run()
238 .await
239 .map_err(|e| helper::classify_cmd_error(e, &ctx))?;
240 if !output.status.success() {
241 return Err(helper::classify_stderr(
242 &String::from_utf8_lossy(&output.stderr),
243 &ctx,
244 ));
245 }
246 helper::parse_commit_short_stats(&output.stdout)
247 }
248
249 pub async fn get_tree_entries(
250 &self,
251 revision: &str,
252 path: &Path,
253 ) -> Result<Vec<TreeEntry>, BabyError> {
254 helper::validate_revision(revision)?;
255 helper::validate_path_local_no_escape(path)?;
256 let pool = crate::cache::try_global_cache();
257 let key = if pool.is_some() {
258 match self.rev_parse(revision).await.map_err(helper::facade_error) {
259 Ok(oid) => {
260 let mut k =
261 Vec::with_capacity(oid.as_bytes().len() + 1 + path.as_os_str().len());
262 k.extend_from_slice(oid.as_bytes());
263 k.push(0);
264 k.extend_from_slice(path.to_string_lossy().as_bytes());
265 Some(crate::cache::encode(crate::cache::NS_TREE_ENTRIES, &k))
266 }
267 Err(_) => None,
268 }
269 } else {
270 None
271 };
272 if let (Some(p), Some(key)) = (&pool, &key)
273 && let Ok(Some(v)) = p.store().get(key).await
274 && let Ok(entries) = serde_json::from_slice::<Vec<TreeEntry>>(&v)
275 {
276 return Ok(entries);
277 }
278 let mut cmd = self
279 .spawn_commit_cmd(revision, |dir, env| {
280 helper::build_tree_entries_cmd(dir, env, revision, path)
281 })
282 .await?;
283 let ctx = helper::StderrCtx {
284 revision,
285 path: Some(path),
286 };
287 let output = cmd
288 .run()
289 .await
290 .map_err(|e| helper::classify_cmd_error(e, &ctx))?;
291 if !output.status.success() {
292 return Err(helper::classify_stderr(
293 &String::from_utf8_lossy(&output.stderr),
294 &ctx,
295 ));
296 }
297 let entries = helper::parse_tree_entries(&output.stdout)?;
298 if let (Some(p), Some(key)) = (&pool, &key)
299 && let Ok(v) = serde_json::to_vec(&entries)
300 {
301 p.store().insert(key.clone(), v);
302 }
303 Ok(entries)
304 }
305
306 pub async fn tree_entry_at(
307 &self,
308 revision: &str,
309 path: &Path,
310 ) -> Result<Option<TreeEntry>, BabyError> {
311 helper::validate_revision(revision)?;
312 helper::validate_path_local_no_escape(path)?;
313 let raw = path.to_string_lossy();
314 let clean = raw.trim().trim_start_matches("./");
315 if clean.is_empty() || clean == "." {
316 return Ok(None);
317 }
318 let (parent, base) = match clean.rsplit_once('/') {
319 Some((p, b)) => (p, b),
320 None => ("", clean),
321 };
322 if base.is_empty() {
323 return Ok(None);
324 }
325 let treeish = if parent.is_empty() {
326 format!("{}:", revision)
327 } else {
328 format!("{}:{}", revision, parent)
329 };
330 let mut cmd = self
331 .spawn_commit_cmd(revision, |dir, env| {
332 let args: Vec<String> = ["-c", "core.quotepath=off", "ls-tree", treeish.as_str()]
333 .into_iter()
334 .map(String::from)
335 .collect();
336 crate::share::git_cmd(helper::CALLER, dir, env, None, args)
337 })
338 .await?;
339 let ctx = helper::StderrCtx {
340 revision,
341 path: Some(path),
342 };
343 let output = cmd
344 .run()
345 .await
346 .map_err(|e| helper::classify_cmd_error(e, &ctx))?;
347 if !output.status.success() {
348 return Err(helper::classify_stderr(
349 &String::from_utf8_lossy(&output.stderr),
350 &ctx,
351 ));
352 }
353 let entries = helper::parse_tree_entries(&output.stdout)?;
354 Ok(entries.into_iter().find(|e| e.name == base))
355 }
356
357 pub async fn last_commit_for_path(
358 &self,
359 revision: &str,
360 path: &Path,
361 ) -> Result<CommitInfo, BabyError> {
362 helper::validate_revision(revision)?;
363 helper::validate_path_local_no_escape(path)?;
364 let mut cmd = self
365 .spawn_commit_cmd(revision, |dir, env| {
366 helper::build_last_commit_for_path_cmd(dir, env, revision, path)
367 })
368 .await?;
369 let ctx = helper::StderrCtx {
370 revision,
371 path: Some(path),
372 };
373 let output = cmd
374 .run()
375 .await
376 .map_err(|e| helper::classify_cmd_error(e, &ctx))?;
377 if !output.status.success() {
378 return Err(helper::classify_stderr(
379 &String::from_utf8_lossy(&output.stderr),
380 &ctx,
381 ));
382 }
383 let mut commits = helper::parse_log_porcelain(&output.stdout)?;
384 commits
385 .pop()
386 .ok_or_else(|| BabyError::RevisionNotFound(revision.to_string()))
387 }
388
389 async fn spawn_commit_cmd<F>(
390 &self,
391 rev: &str,
392 builder: F,
393 ) -> Result<crate::command::cmd::Cmd, BabyError>
394 where
395 F: FnOnce(&Path, crate::command::env::Env) -> crate::command::cmd::Cmd,
396 {
397 let _ = rev;
398 let dir = self
399 .facade
400 .git_repo_dir()
401 .await
402 .map_err(helper::facade_error)?;
403 let alternates = self
404 .facade
405 .git_alternate_object_directories()
406 .await
407 .map_err(helper::facade_error)?;
408 let env = helper::build_env(&alternates);
409 Ok(builder(&dir, env))
410 }
411}
412
413use crate::command::error::CmdError;
Added lib/gitbaby/src/compare/helper.rs lib/gitbaby/src/compare/helper.rs +122 −0 View lib/gitbaby/src/compare/helper.rs
@@ -0,0 +1,122 @@
1use std::path::Path;
2
3use crate::command::cmd::Cmd;
4use crate::command::env::Env;
5use crate::compare::types::DivergeObject;
6use crate::error::BabyError;
7
8pub fn build_diverging_commits_cmd(
9 dir: &Path,
10 env: Env,
11 base: &str,
12 target: &str,
13) -> Result<Cmd, BabyError> {
14 crate::share::validate_revision(base)?;
15 crate::share::validate_revision(target)?;
16 let args = vec![
17 "rev-list".to_string(),
18 "--count".to_string(),
19 "--left-right".to_string(),
20 "--end-of-options".to_string(),
21 format!("{}...{}", base, target),
22 ];
23 Ok(crate::share::git_cmd(
24 "gitbaby::compare",
25 dir,
26 env,
27 None,
28 args,
29 ))
30}
31
32pub fn build_commit_ids_between_reverse_cmd(
33 dir: &Path,
34 env: Env,
35 start: &str,
36 end: &str,
37 not_ref: Option<&str>,
38 limit: Option<u32>,
39) -> Result<Cmd, BabyError> {
40 crate::share::validate_revision(start)?;
41 crate::share::validate_revision(end)?;
42 if let Some(n) = not_ref {
43 crate::share::validate_revision(n)?;
44 }
45 let mut args = vec!["rev-list".to_string()];
46 if let Some(n) = not_ref {
47 args.push("--not".to_string());
48 args.push(n.to_string());
49 }
50 if let Some(l) = limit {
51 args.push("--max-count".to_string());
52 args.push(l.to_string());
53 }
54 args.push("--reverse".to_string());
55 args.push("--end-of-options".to_string());
56 args.push(format!("{}..{}", start, end));
57 Ok(crate::share::git_cmd(
58 "gitbaby::compare",
59 dir,
60 env,
61 None,
62 args,
63 ))
64}
65
66pub fn classify_cmd_error(err: crate::command::error::CmdError) -> BabyError {
67 use crate::command::error::CmdError;
68 match err {
69 CmdError::NonZeroExit { stderr, .. } => {
70 if stderr.trim().is_empty() {
71 BabyError::Custom("rev-list exited non-zero with empty stderr".to_string())
72 } else {
73 BabyError::Custom(format!("rev-list failed: {}", stderr.trim()))
74 }
75 }
76 CmdError::Spawn { prog, source } => crate::share::classify_spawn_error(prog, source),
77 other => BabyError::from(other),
78 }
79}
80
81pub fn parse_left_right_count(output: &[u8]) -> Result<DivergeObject, BabyError> {
82 let text = String::from_utf8_lossy(output);
83 let mut parts = text.split_whitespace();
84 let ahead = match parts.next() {
85 Some(s) => s.parse::<u32>().map_err(|e| {
86 BabyError::Custom(format!(
87 "expected digit before tab in `rev-list --left-right --count`, got `{}`: {}",
88 text.trim(),
89 e
90 ))
91 })?,
92 None => {
93 return Err(BabyError::Custom(
94 "empty `rev-list --left-right --count` output".to_string(),
95 ));
96 }
97 };
98 let behind = match parts.next() {
99 Some(s) => s.parse::<u32>().map_err(|e| {
100 BabyError::Custom(format!(
101 "expected digit after tab in `rev-list --left-right --count`, got `{}`: {}",
102 text.trim(),
103 e
104 ))
105 })?,
106 None => 0,
107 };
108 Ok(DivergeObject { ahead, behind })
109}
110
111pub fn parse_rev_list_oids(output: &[u8]) -> Result<Vec<gix::ObjectId>, BabyError> {
112 let mut oids = Vec::new();
113 for line in String::from_utf8_lossy(output).lines() {
114 let trimmed = line.trim();
115 if trimmed.is_empty() {
116 continue;
117 }
118 let oid = crate::share::parse_object_id(trimmed)?;
119 oids.push(oid);
120 }
121 Ok(oids)
122}
Added lib/gitbaby/src/compare/mod.rs lib/gitbaby/src/compare/mod.rs +5 −0 View lib/gitbaby/src/compare/mod.rs
@@ -0,0 +1,5 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::DivergeObject;
Added lib/gitbaby/src/compare/types.rs lib/gitbaby/src/compare/types.rs +15 −0 View lib/gitbaby/src/compare/types.rs
@@ -0,0 +1,15 @@
1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6pub struct DivergeObject {
7 pub ahead: u32,
8 pub behind: u32,
9}
10
11impl fmt::Display for DivergeObject {
12 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13 write!(f, "ahead={}, behind={}", self.ahead, self.behind)
14 }
15}
Added lib/gitbaby/src/compare/usecase.rs lib/gitbaby/src/compare/usecase.rs +91 −0 View lib/gitbaby/src/compare/usecase.rs
@@ -0,0 +1,91 @@
1use crate::GitBaby;
2use crate::compare::helper;
3use crate::compare::types::DivergeObject;
4use crate::error::BabyError;
5use crate::share;
6
7impl GitBaby {
8 pub async fn diverging_commits(
9 &self,
10 base: &str,
11 target: &str,
12 ) -> Result<DivergeObject, BabyError> {
13 share::validate_revision_non_empty(base)?;
14 share::validate_revision_non_empty(target)?;
15 let dir = self
16 .facade
17 .git_repo_dir()
18 .await
19 .map_err(share::facade_error)?;
20 let alternates = self
21 .facade
22 .git_alternate_object_directories()
23 .await
24 .map_err(share::facade_error)?;
25 let env = share::build_env_safe(&alternates);
26 let mut cmd = helper::build_diverging_commits_cmd(&dir, env, base, target)?;
27 let output = cmd.run_soft().await.map_err(helper::classify_cmd_error)?;
28 if !output.status.success() {
29 return Err(BabyError::Custom(format!(
30 "git rev-list --left-right failed: {}",
31 String::from_utf8_lossy(&output.stderr).trim()
32 )));
33 }
34 helper::parse_left_right_count(&output.stdout)
35 }
36
37 pub async fn commit_ids_between_reverse(
38 &self,
39 start: &str,
40 end: &str,
41 not_ref: Option<&str>,
42 limit: Option<u32>,
43 ) -> Result<Vec<gix::ObjectId>, BabyError> {
44 share::validate_revision_non_empty(start)?;
45 share::validate_revision_non_empty(end)?;
46 let dir = self
47 .facade
48 .git_repo_dir()
49 .await
50 .map_err(share::facade_error)?;
51 let alternates = self
52 .facade
53 .git_alternate_object_directories()
54 .await
55 .map_err(share::facade_error)?;
56 let env = share::build_env_safe(&alternates);
57 let mut cmd =
58 helper::build_commit_ids_between_reverse_cmd(&dir, env, start, end, not_ref, limit)?;
59 match cmd.run().await {
60 Ok(output) => {
61 if !output.status.success() {
62 return Err(BabyError::Custom(format!(
63 "git rev-list failed: {}",
64 String::from_utf8_lossy(&output.stderr).trim()
65 )));
66 }
67 helper::parse_rev_list_oids(&output.stdout)
68 }
69 Err(e) => {
70 // A missing `--not` exclusion ref is not fatal: treat it as
71 // "no commits excluded". Any other non-zero exit (bad start/end
72 // revision, etc.) must propagate rather than be masked as an
73 // empty result.
74 if let crate::command::error::CmdError::NonZeroExit { stderr, .. } = &e
75 && let Some(n) = not_ref
76 {
77 let s = stderr.trim();
78 if !s.is_empty()
79 && (s.contains("unknown revision")
80 || s.contains("ambiguous argument")
81 || s.contains("bad revision"))
82 && s.contains(n)
83 {
84 return Ok(Vec::new());
85 }
86 }
87 Err(helper::classify_cmd_error(e))
88 }
89 }
90 }
91}
Added lib/gitbaby/src/config/helper.rs lib/gitbaby/src/config/helper.rs +134 −0 View lib/gitbaby/src/config/helper.rs
@@ -0,0 +1,134 @@
1use std::path::Path;
2
3use crate::command::cmd::Cmd;
4use crate::command::env::Env;
5use crate::config::types::{ConfigEntry, ConfigScope};
6use crate::error::BabyError;
7
8fn share_scope(scope: &ConfigScope) -> crate::share::ConfigScope {
9 match scope.0.as_str() {
10 "global" => crate::share::ConfigScope::Global,
11 _ => crate::share::ConfigScope::Local,
12 }
13}
14
15pub fn run_get(dir: &Path, env: Env, scope: &ConfigScope, key: &str) -> Result<Cmd, BabyError> {
16 crate::share::build_config_cmd(
17 "gitbaby::config",
18 dir,
19 env,
20 share_scope(scope),
21 crate::share::ConfigOp::Get,
22 key,
23 None,
24 )
25}
26
27pub fn run_get_all(dir: &Path, env: Env, scope: &ConfigScope, key: &str) -> Result<Cmd, BabyError> {
28 crate::share::build_config_cmd(
29 "gitbaby::config",
30 dir,
31 env,
32 share_scope(scope),
33 crate::share::ConfigOp::GetAll,
34 key,
35 None,
36 )
37}
38
39pub fn run_get_regexp(
40 dir: &Path,
41 env: Env,
42 scope: &ConfigScope,
43 pattern: &str,
44) -> Result<Cmd, BabyError> {
45 crate::share::build_config_cmd(
46 "gitbaby::config",
47 dir,
48 env,
49 share_scope(scope),
50 crate::share::ConfigOp::GetRegexp,
51 pattern,
52 None,
53 )
54}
55
56pub fn run_set(
57 dir: &Path,
58 env: Env,
59 scope: &ConfigScope,
60 key: &str,
61 value: &str,
62) -> Result<Cmd, BabyError> {
63 crate::share::build_config_cmd(
64 "gitbaby::config",
65 dir,
66 env,
67 share_scope(scope),
68 crate::share::ConfigOp::Set,
69 key,
70 Some(value),
71 )
72}
73
74pub fn run_add(
75 dir: &Path,
76 env: Env,
77 scope: &ConfigScope,
78 key: &str,
79 value: &str,
80) -> Result<Cmd, BabyError> {
81 crate::share::build_config_cmd(
82 "gitbaby::config",
83 dir,
84 env,
85 share_scope(scope),
86 crate::share::ConfigOp::Add,
87 key,
88 Some(value),
89 )
90}
91
92pub fn run_unset_all(
93 dir: &Path,
94 env: Env,
95 scope: &ConfigScope,
96 key: &str,
97) -> Result<Cmd, BabyError> {
98 crate::share::build_config_cmd(
99 "gitbaby::config",
100 dir,
101 env,
102 share_scope(scope),
103 crate::share::ConfigOp::UnsetAll,
104 key,
105 None,
106 )
107}
108
109pub fn classify_set_failure(key: &str, stderr: &str) -> BabyError {
110 BabyError::ConfigSetFailed {
111 key: key.to_string(),
112 stderr: stderr.to_string(),
113 }
114}
115
116pub fn classify_get_failure(key: &str, stderr: &str) -> BabyError {
117 BabyError::ConfigGetFailed {
118 key: key.to_string(),
119 stderr: stderr.to_string(),
120 }
121}
122
123pub fn parse_kv_lines(output: &[u8]) -> Vec<ConfigEntry> {
124 String::from_utf8_lossy(output)
125 .lines()
126 .filter_map(|line| {
127 let (k, v) = line.split_once('=')?;
128 Some(ConfigEntry {
129 key: k.to_string(),
130 value: v.to_string(),
131 })
132 })
133 .collect()
134}
Added lib/gitbaby/src/config/mod.rs lib/gitbaby/src/config/mod.rs +5 −0 View lib/gitbaby/src/config/mod.rs
@@ -0,0 +1,5 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{ConfigEntry, ConfigScope};
Added lib/gitbaby/src/config/types.rs lib/gitbaby/src/config/types.rs +27 −0 View lib/gitbaby/src/config/types.rs
@@ -0,0 +1,27 @@
1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub struct ConfigEntry {
7 pub key: String,
8 pub value: String,
9}
10
11impl fmt::Display for ConfigEntry {
12 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13 write!(f, "{}={}", self.key, self.value)
14 }
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ConfigScope(pub String);
19
20impl ConfigScope {
21 pub fn global() -> Self {
22 Self("global".to_string())
23 }
24 pub fn local() -> Self {
25 Self("local".to_string())
26 }
27}
Added lib/gitbaby/src/config/usecase.rs lib/gitbaby/src/config/usecase.rs +245 −0 View lib/gitbaby/src/config/usecase.rs
@@ -0,0 +1,245 @@
1use crate::GitBaby;
2use crate::config::helper;
3use crate::config::types::{ConfigEntry, ConfigScope};
4use crate::error::BabyError;
5use crate::share;
6
7async fn build_env_for_scope(
8 gitbaby: &GitBaby,
9 scope: &ConfigScope,
10) -> Result<(std::path::PathBuf, crate::command::env::Env), BabyError> {
11 let _ = scope;
12 let dir = gitbaby
13 .facade
14 .git_repo_dir()
15 .await
16 .map_err(share::facade_error)?;
17 let alternates = gitbaby
18 .facade
19 .git_alternate_object_directories()
20 .await
21 .map_err(share::facade_error)?;
22 let env = share::build_env_safe(&alternates);
23 Ok((dir, env))
24}
25
26impl GitBaby {
27 pub async fn config_get(&self, key: &str) -> Result<Option<String>, BabyError> {
28 self.config_get_with_scope(key, &ConfigScope::local()).await
29 }
30
31 pub async fn config_get_global(&self, key: &str) -> Result<Option<String>, BabyError> {
32 self.config_get_with_scope(key, &ConfigScope::global())
33 .await
34 }
35
36 pub async fn config_set(&self, key: &str, value: &str) -> Result<(), BabyError> {
37 self.config_set_with_scope(key, value, &ConfigScope::local())
38 .await
39 }
40
41 pub async fn config_set_global(&self, key: &str, value: &str) -> Result<(), BabyError> {
42 self.config_set_with_scope(key, value, &ConfigScope::global())
43 .await
44 }
45
46 pub async fn config_set_if_absent(&self, key: &str, value: &str) -> Result<(), BabyError> {
47 self.config_set_if_absent_with_scope(key, value, &ConfigScope::local())
48 .await
49 }
50
51 pub async fn config_set_if_absent_global(
52 &self,
53 key: &str,
54 value: &str,
55 ) -> Result<(), BabyError> {
56 self.config_set_if_absent_with_scope(key, value, &ConfigScope::global())
57 .await
58 }
59
60 pub async fn config_add(&self, key: &str, value: &str) -> Result<(), BabyError> {
61 self.config_add_with_scope(key, value, &ConfigScope::local())
62 .await
63 }
64
65 pub async fn config_add_global(&self, key: &str, value: &str) -> Result<(), BabyError> {
66 self.config_add_with_scope(key, value, &ConfigScope::global())
67 .await
68 }
69
70 pub async fn config_unset_all(&self, key: &str) -> Result<(), BabyError> {
71 self.config_unset_all_with_scope(key, &ConfigScope::local())
72 .await
73 }
74
75 pub async fn config_unset_all_global(&self, key: &str) -> Result<(), BabyError> {
76 self.config_unset_all_with_scope(key, &ConfigScope::global())
77 .await
78 }
79
80 pub async fn config_get_all(&self, key: &str) -> Result<Vec<String>, BabyError> {
81 self.config_get_all_with_scope(key, &ConfigScope::local())
82 .await
83 }
84
85 pub async fn config_get_all_global(&self, key: &str) -> Result<Vec<String>, BabyError> {
86 self.config_get_all_with_scope(key, &ConfigScope::global())
87 .await
88 }
89
90 pub async fn config_get_regexp(&self, pattern: &str) -> Result<Vec<ConfigEntry>, BabyError> {
91 self.config_get_regexp_with_scope(pattern, &ConfigScope::local())
92 .await
93 }
94
95 pub async fn config_get_regexp_global(
96 &self,
97 pattern: &str,
98 ) -> Result<Vec<ConfigEntry>, BabyError> {
99 self.config_get_regexp_with_scope(pattern, &ConfigScope::global())
100 .await
101 }
102
103 pub async fn managed_config_set(&self, key: &str, value: &str) -> Result<(), BabyError> {
104 self.config_set_with_scope(key, value, &ConfigScope::local())
105 .await
106 }
107
108 pub async fn managed_config_add(&self, key: &str, value: &str) -> Result<(), BabyError> {
109 self.config_add_with_scope(key, value, &ConfigScope::local())
110 .await
111 }
112
113 async fn config_get_with_scope(
114 &self,
115 key: &str,
116 scope: &ConfigScope,
117 ) -> Result<Option<String>, BabyError> {
118 let (dir, env) = build_env_for_scope(self, scope).await?;
119 let mut cmd = helper::run_get(&dir, env, scope, key)?;
120 let output = cmd.run_soft().await.map_err(BabyError::from)?;
121 if output.status.success() {
122 let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
123 return Ok(Some(text));
124 }
125 let stderr = String::from_utf8_lossy(&output.stderr);
126 if output.status.code() == Some(1) && stderr.trim().is_empty() {
127 return Ok(None);
128 }
129 if output.status.code() == Some(1) && stderr.contains("key does not exist") {
130 return Ok(None);
131 }
132 Err(helper::classify_get_failure(
133 key,
134 &String::from_utf8_lossy(&output.stderr),
135 ))
136 }
137
138 async fn config_set_with_scope(
139 &self,
140 key: &str,
141 value: &str,
142 scope: &ConfigScope,
143 ) -> Result<(), BabyError> {
144 let (dir, env) = build_env_for_scope(self, scope).await?;
145 let mut cmd = helper::run_set(&dir, env, scope, key, value)?;
146 let output = cmd.run_soft().await.map_err(BabyError::from)?;
147 if output.status.success() {
148 return Ok(());
149 }
150 Err(helper::classify_set_failure(
151 key,
152 &String::from_utf8_lossy(&output.stderr),
153 ))
154 }
155
156 async fn config_set_if_absent_with_scope(
157 &self,
158 key: &str,
159 value: &str,
160 scope: &ConfigScope,
161 ) -> Result<(), BabyError> {
162 if self.config_get_with_scope(key, scope).await?.is_some() {
163 return Ok(());
164 }
165 self.config_set_with_scope(key, value, scope).await
166 }
167
168 async fn config_add_with_scope(
169 &self,
170 key: &str,
171 value: &str,
172 scope: &ConfigScope,
173 ) -> Result<(), BabyError> {
174 let (dir, env) = build_env_for_scope(self, scope).await?;
175 let mut cmd = helper::run_add(&dir, env, scope, key, value)?;
176 let output = cmd.run_soft().await.map_err(BabyError::from)?;
177 if output.status.success() {
178 return Ok(());
179 }
180 Err(helper::classify_set_failure(
181 key,
182 &String::from_utf8_lossy(&output.stderr),
183 ))
184 }
185
186 async fn config_unset_all_with_scope(
187 &self,
188 key: &str,
189 scope: &ConfigScope,
190 ) -> Result<(), BabyError> {
191 let (dir, env) = build_env_for_scope(self, scope).await?;
192 let mut cmd = helper::run_unset_all(&dir, env, scope, key)?;
193 let output = cmd.run_soft().await.map_err(BabyError::from)?;
194 if output.status.success() {
195 return Ok(());
196 }
197 let stderr = String::from_utf8_lossy(&output.stderr);
198 if output.status.code() == Some(5) {
199 return Ok(());
200 }
201 if output.status.code() == Some(1) && stderr.trim().is_empty() {
202 return Ok(());
203 }
204 Err(helper::classify_set_failure(key, &stderr))
205 }
206
207 async fn config_get_all_with_scope(
208 &self,
209 key: &str,
210 scope: &ConfigScope,
211 ) -> Result<Vec<String>, BabyError> {
212 let (dir, env) = build_env_for_scope(self, scope).await?;
213 let mut cmd = helper::run_get_all(&dir, env, scope, key)?;
214 let output = cmd.run_soft().await.map_err(BabyError::from)?;
215 if output.status.success() {
216 return Ok(String::from_utf8_lossy(&output.stdout)
217 .lines()
218 .map(|s| s.to_string())
219 .collect());
220 }
221 let stderr = String::from_utf8_lossy(&output.stderr);
222 if output.status.code() == Some(1) && stderr.trim().is_empty() {
223 return Ok(Vec::new());
224 }
225 Err(helper::classify_get_failure(key, &stderr))
226 }
227
228 async fn config_get_regexp_with_scope(
229 &self,
230 pattern: &str,
231 scope: &ConfigScope,
232 ) -> Result<Vec<ConfigEntry>, BabyError> {
233 let (dir, env) = build_env_for_scope(self, scope).await?;
234 let mut cmd = helper::run_get_regexp(&dir, env, scope, pattern)?;
235 let output = cmd.run_soft().await.map_err(BabyError::from)?;
236 if output.status.success() {
237 return Ok(helper::parse_kv_lines(&output.stdout));
238 }
239 let stderr = String::from_utf8_lossy(&output.stderr);
240 if output.status.code() == Some(1) && stderr.trim().is_empty() {
241 return Ok(Vec::new());
242 }
243 Err(helper::classify_get_failure(pattern, &stderr))
244 }
245}
Added lib/gitbaby/src/conflict/helper.rs lib/gitbaby/src/conflict/helper.rs +147 −0 View lib/gitbaby/src/conflict/helper.rs
@@ -0,0 +1,147 @@
1use std::path::Path;
2
3use gix::ObjectId;
4
5use crate::command::cmd::Cmd;
6use crate::command::env::Env;
7use crate::command::error::CmdError;
8use crate::conflict::types::{ConflictFileHeader, ListConflictOptions, MergeTreeResult};
9use crate::error::BabyError;
10use crate::share;
11
12pub const CALLER: &str = "gitbaby::conflict";
13
14pub fn build_merge_tree_cmd(
15 dir: &Path,
16 env: Env,
17 ours: &str,
18 theirs: &str,
19 merge_base: Option<&str>,
20 allow_tree_conflicts: bool,
21) -> Cmd {
22 let mut args: Vec<String> = vec![
23 "merge-tree".into(),
24 "--write-tree".into(),
25 "-z".into(),
26 "--name-only".into(),
27 "--no-messages".into(),
28 ];
29 if allow_tree_conflicts {
30 args.push("--allow-unrelated-histories".into());
31 }
32 if let Some(mb) = merge_base {
33 args.push(format!("--merge-base={}", mb));
34 args.push(ours.into());
35 args.push(theirs.into());
36 } else {
37 args.push(ours.into());
38 args.push(theirs.into());
39 }
40 share::git_cmd(CALLER, dir, env, None, args)
41}
42
43pub fn build_list_conflict_files_cmd(
44 dir: &Path,
45 env: Env,
46 ours: &str,
47 theirs: &str,
48 opts: &ListConflictOptions,
49) -> Cmd {
50 let mut args: Vec<String> = vec!["merge-tree".into(), "-z".into()];
51 if opts.allow_tree_conflicts {
52 args.push("--allow-unrelated-histories".into());
53 }
54 args.push(ours.into());
55 args.push(theirs.into());
56 share::git_cmd(CALLER, dir, env, None, args)
57}
58
59pub fn classify_stderr(stderr: &str, ours: &str, theirs: &str) -> BabyError {
60 if stderr.contains("fatal: bad revision") {
61 return BabyError::RevisionNotFound(format!("{}..{}", ours, theirs));
62 }
63 BabyError::Custom(format!("git merge-tree failed: {}", stderr.trim()))
64}
65
66pub fn classify_cmd_error(err: CmdError, ours: &str, theirs: &str) -> BabyError {
67 match err {
68 CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, ours, theirs),
69 CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
70 other => BabyError::from(other),
71 }
72}
73
74pub fn parse_merge_tree_output(
75 data: &[u8],
76 ours_oid: ObjectId,
77) -> Result<MergeTreeResult, BabyError> {
78 let s = String::from_utf8_lossy(data);
79 let mut iter = s.split('\0');
80 let first = iter.next().unwrap_or("");
81 let tree_oid = ObjectId::from_hex(first.as_bytes()).map_err(|source| {
82 BabyError::Custom(format!("invalid merge-tree oid `{}`: {}", first, source))
83 })?;
84 let conflict_files: Vec<std::path::PathBuf> = iter
85 .filter(|p| !p.is_empty())
86 .map(std::path::PathBuf::from)
87 .collect();
88 let has_conflicts = !conflict_files.is_empty();
89 let _ = ours_oid;
90 Ok(MergeTreeResult {
91 tree_oid,
92 has_conflicts,
93 conflict_files,
94 })
95}
96
97pub fn parse_list_conflict_files(
98 data: &[u8],
99 ours_oid: ObjectId,
100) -> Result<Vec<ConflictFileHeader>, BabyError> {
101 let s = String::from_utf8_lossy(data);
102 let mut headers = Vec::new();
103 let mut current_mode: Option<u32> = None;
104 let mut their_path: Option<std::path::PathBuf> = None;
105 let mut our_path: Option<std::path::PathBuf> = None;
106 let mut ancestor_path: Option<std::path::PathBuf> = None;
107 let mut mode_count: u8 = 0;
108 for part in s.split('\0') {
109 if part.is_empty() {
110 continue;
111 }
112 if mode_count < 3
113 && let Ok(m) = share::parse_git_mode(part)
114 {
115 current_mode = Some(m);
116 mode_count += 1;
117 continue;
118 }
119 match mode_count {
120 1 => {
121 our_path = Some(std::path::PathBuf::from(part));
122 mode_count = 2;
123 current_mode = None;
124 }
125 2 => {
126 their_path = Some(std::path::PathBuf::from(part));
127 mode_count = 3;
128 current_mode = None;
129 }
130 _ => {
131 if ancestor_path.is_none() {
132 ancestor_path = Some(std::path::PathBuf::from(part));
133 headers.push(ConflictFileHeader {
134 our_commit_oid: ours_oid,
135 their_path: their_path.take(),
136 our_path: our_path.take(),
137 ancestor_path: ancestor_path.take(),
138 our_mode: current_mode.unwrap_or(0),
139 });
140 mode_count = 0;
141 current_mode = None;
142 }
143 }
144 }
145 }
146 Ok(headers)
147}
Added lib/gitbaby/src/conflict/mod.rs lib/gitbaby/src/conflict/mod.rs +8 −0 View lib/gitbaby/src/conflict/mod.rs
@@ -0,0 +1,8 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{
6 ConflictFile, ConflictFileHeader, ConflictResolution, ListConflictOptions, MergeStage,
7 MergeTreeResult, ResolveConflictsInput,
8};
Added lib/gitbaby/src/conflict/types.rs lib/gitbaby/src/conflict/types.rs +58 −0 View lib/gitbaby/src/conflict/types.rs
@@ -0,0 +1,58 @@
1use std::path::PathBuf;
2
3use gix::ObjectId;
4use serde::{Deserialize, Serialize};
5
6use crate::commit::types::Signature;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum MergeStage {
10 Ancestor,
11 Ours,
12 Theirs,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct ConflictFileHeader {
17 pub our_commit_oid: ObjectId,
18 pub their_path: Option<PathBuf>,
19 pub our_path: Option<PathBuf>,
20 pub ancestor_path: Option<PathBuf>,
21 pub our_mode: u32,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ConflictFile {
26 pub header: ConflictFileHeader,
27 pub content: Option<String>,
28}
29
30#[derive(Debug, Default, Clone, Serialize, Deserialize)]
31pub struct ListConflictOptions {
32 pub allow_tree_conflicts: bool,
33 pub skip_content: bool,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct MergeTreeResult {
38 pub tree_oid: ObjectId,
39 pub has_conflicts: bool,
40 pub conflict_files: Vec<PathBuf>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ResolveConflictsInput {
45 pub our_commit_oid: ObjectId,
46 pub their_commit_oid: ObjectId,
47 pub source_branch: String,
48 pub target_branch: String,
49 pub commit_message: String,
50 pub author: Signature,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct ConflictResolution {
55 pub old_path: Option<PathBuf>,
56 pub new_path: PathBuf,
57 pub content: String,
58}
Added lib/gitbaby/src/conflict/usecase.rs lib/gitbaby/src/conflict/usecase.rs +139 −0 View lib/gitbaby/src/conflict/usecase.rs
@@ -0,0 +1,139 @@
1use std::path::Path;
2
3use gix::ObjectId;
4
5use crate::GitBaby;
6use crate::command::error::CmdError;
7use crate::conflict::helper;
8use crate::conflict::types::{
9 ConflictFile, ConflictResolution, ListConflictOptions, MergeTreeResult, ResolveConflictsInput,
10};
11use crate::error::BabyError;
12
13impl GitBaby {
14 pub async fn list_conflict_files(
15 &self,
16 our: &str,
17 their: &str,
18 opts: &ListConflictOptions,
19 ) -> Result<Vec<ConflictFile>, BabyError> {
20 crate::share::validate_revision(our)?;
21 crate::share::validate_revision(their)?;
22 let ours_oid = ObjectId::from_hex(our.as_bytes()).map_err(|source| {
23 BabyError::Custom(format!("invalid our oid `{}`: {}", our, source))
24 })?;
25 let mut cmd = self.spawn_conflict_cmd(our, their, opts).await?;
26 let output = match cmd.run().await {
27 Ok(o) => o,
28 Err(CmdError::NonZeroExit { stderr, .. }) => {
29 return Err(helper::classify_stderr(&stderr, our, their));
30 }
31 Err(e) => return Err(helper::classify_cmd_error(e, our, their)),
32 };
33 let headers = helper::parse_list_conflict_files(&output.stdout, ours_oid)?;
34 let files: Vec<ConflictFile> = headers
35 .into_iter()
36 .map(|header| ConflictFile {
37 header,
38 content: None,
39 })
40 .collect();
41 Ok(files)
42 }
43
44 pub async fn merge_tree(
45 &self,
46 ours: &str,
47 theirs: &str,
48 merge_base: Option<&str>,
49 allow_tree_conflicts: bool,
50 ) -> Result<MergeTreeResult, BabyError> {
51 crate::share::validate_revision(ours)?;
52 crate::share::validate_revision(theirs)?;
53 if let Some(mb) = merge_base {
54 crate::share::validate_revision(mb)?;
55 }
56 let ours_oid = ObjectId::from_hex(ours.as_bytes()).map_err(|source| {
57 BabyError::Custom(format!("invalid ours oid `{}`: {}", ours, source))
58 })?;
59 let mut cmd = self
60 .spawn_merge_tree_cmd(ours, theirs, merge_base, allow_tree_conflicts)
61 .await?;
62 let output = match cmd.run().await {
63 Ok(o) => o,
64 Err(CmdError::NonZeroExit { ref stderr, .. }) => {
65 return Err(helper::classify_stderr(stderr, ours, theirs));
66 }
67 Err(e) => return Err(helper::classify_cmd_error(e, ours, theirs)),
68 };
69 let stdout = output.stdout.clone();
70 let _ = output;
71 helper::parse_merge_tree_output(&stdout, ours_oid)
72 }
73
74 pub async fn resolve_conflicts<I>(
75 &self,
76 _input: &ResolveConflictsInput,
77 resolutions: I,
78 ) -> Result<ObjectId, BabyError>
79 where
80 I: IntoIterator<Item = ConflictResolution>,
81 {
82 let blobs: Vec<ConflictResolution> = resolutions.into_iter().collect();
83 let _ = blobs.len();
84 Err(BabyError::Unimplemented("resolve_conflicts"))
85 }
86
87 async fn spawn_conflict_cmd(
88 &self,
89 ours: &str,
90 their: &str,
91 opts: &ListConflictOptions,
92 ) -> Result<crate::command::cmd::Cmd, BabyError> {
93 let dir = self
94 .facade
95 .git_repo_dir()
96 .await
97 .map_err(crate::share::facade_error)?;
98 let alternates = self
99 .facade
100 .git_alternate_object_directories()
101 .await
102 .map_err(crate::share::facade_error)?;
103 let env = crate::share::build_env_safe(&alternates);
104 Ok(helper::build_list_conflict_files_cmd(
105 &dir, env, ours, their, opts,
106 ))
107 }
108
109 async fn spawn_merge_tree_cmd(
110 &self,
111 ours: &str,
112 theirs: &str,
113 merge_base: Option<&str>,
114 allow_tree_conflicts: bool,
115 ) -> Result<crate::command::cmd::Cmd, BabyError> {
116 let dir = self
117 .facade
118 .git_repo_dir()
119 .await
120 .map_err(crate::share::facade_error)?;
121 let alternates = self
122 .facade
123 .git_alternate_object_directories()
124 .await
125 .map_err(crate::share::facade_error)?;
126 let env = crate::share::build_env_safe(&alternates);
127 Ok(helper::build_merge_tree_cmd(
128 &dir,
129 env,
130 ours,
131 theirs,
132 merge_base,
133 allow_tree_conflicts,
134 ))
135 }
136
137 #[allow(dead_code)]
138 fn _path_marker(_p: &Path) {}
139}
Added lib/gitbaby/src/diff/helper.rs lib/gitbaby/src/diff/helper.rs +588 −0 View lib/gitbaby/src/diff/helper.rs
@@ -0,0 +1,588 @@
1use std::path::{Path, PathBuf};
2
3use gix::ObjectId;
4
5use crate::command::cmd::Cmd;
6use crate::command::env::Env;
7use crate::command::error::CmdError;
8use crate::diff::types::{
9 ChangedPath, ChangedPathDiff, ChangedPathRequest, DiffOptions, DiffRequest, DiffStatus,
10 NumStat, RangeDiffSpec, ShortStat, WhitespaceMode,
11};
12use crate::error::BabyError;
13use crate::share;
14
15pub const CALLER: &str = "gitbaby::diff";
16
17pub fn validate_paths(paths: &[PathBuf]) -> Result<(), BabyError> {
18 for p in paths {
19 share::validate_local_no_escape(p)?;
20 }
21 Ok(())
22}
23
24pub fn build_diff_args(req: &DiffRequest, opts: &DiffOptions, hex_len: Option<u8>) -> Vec<String> {
25 let mut args: Vec<String> = vec!["diff".into(), "--full-index".into()];
26 let abbrev = hex_len
27 .map(|n| format!("--abbrev={}", n))
28 .unwrap_or_else(|| "--abbrev=40".into());
29 args.push(abbrev);
30 match opts.ignore_whitespace {
31 Some(WhitespaceMode::IgnoreSpaceChange) => args.push("--ignore-space-change".into()),
32 Some(WhitespaceMode::IgnoreAllSpace) => args.push("--ignore-all-space".into()),
33 Some(WhitespaceMode::None) | None => {}
34 }
35 if let Some(threshold) = opts.detect_renames {
36 args.push(format!("--find-renames={}%", threshold));
37 }
38 if opts.word_diff {
39 args.push("--word-diff=porcelain".into());
40 }
41 args.push(req.left.to_hex().to_string());
42 args.push(req.right.to_hex().to_string());
43 if let Some(paths) = &opts.paths
44 && !paths.is_empty()
45 {
46 args.push("--".into());
47 for p in paths {
48 args.push(share::to_string_lossy_owned(p));
49 }
50 }
51 args
52}
53
54pub fn build_raw_diff_cmd(
55 dir: &Path,
56
57 env: Env,
58 req: &DiffRequest,
59 opts: &DiffOptions,
60 hex_len: Option<u8>,
61) -> Cmd {
62 let args = build_diff_args(req, opts, hex_len);
63 share::git_cmd(CALLER, dir, env, None, args)
64}
65
66pub fn build_patch_diff_cmd(
67 dir: &Path,
68
69 env: Env,
70 req: &DiffRequest,
71 _opts: &DiffOptions,
72 _hex_len: Option<u8>,
73) -> Cmd {
74 let args: Vec<String> = vec![
75 "format-patch".into(),
76 "--stdout".into(),
77 format!("{}..{}", req.left.to_hex(), req.right.to_hex()),
78 "--no-signature".into(),
79 ];
80 share::git_cmd(CALLER, dir, env, None, args)
81}
82
83pub fn build_commit_diff_cmd(
84 dir: &Path,
85 env: Env,
86 req: &DiffRequest,
87 opts: &DiffOptions,
88 hex_len: Option<u8>,
89) -> Cmd {
90 let mut args: Vec<String> = vec![
91 "-c".into(),
92 "core.quotepath=off".into(),
93 "-c".into(),
94 "diff.noprefix=false".into(),
95 "diff".into(),
96 "--no-color".into(),
97 "--patch".into(),
98 "--raw".into(),
99 ];
100 let abbrev = hex_len
101 .map(|n| format!("--abbrev={}", n))
102 .unwrap_or_else(|| "--abbrev=40".into());
103 args.push(abbrev);
104 args.push("--full-index".into());
105 let empty_tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
106 let is_empty_tree_base = req.left == empty_tree;
107 if is_empty_tree_base {
108 args.push("--no-renames".into());
109 } else if let Some(r) = opts.detect_renames {
110 args.push(format!("--find-renames={}%", r));
111 } else {
112 args.push("--find-renames=50%".into());
113 }
114 match opts.ignore_whitespace {
115 Some(WhitespaceMode::IgnoreSpaceChange) => args.push("--ignore-space-change".into()),
116 Some(WhitespaceMode::IgnoreAllSpace) => args.push("--ignore-all-space".into()),
117 Some(WhitespaceMode::None) | None => {}
118 }
119 if opts.word_diff {
120 args.push("--word-diff=porcelain".into());
121 }
122 args.push(req.left.to_hex().to_string());
123 args.push(req.right.to_hex().to_string());
124 if let Some(paths) = &opts.paths
125 && !paths.is_empty()
126 {
127 args.push("--".into());
128 for p in paths {
129 args.push(share::to_string_lossy_owned(p));
130 }
131 }
132 share::git_cmd(CALLER, dir, env, None, args)
133}
134
135pub fn build_commit_delta_cmd(
136 dir: &Path,
137
138 env: Env,
139 req: &DiffRequest,
140 opts: &DiffOptions,
141 hex_len: Option<u8>,
142) -> Cmd {
143 let mut args: Vec<String> = vec!["diff".into(), "--raw".into(), "--find-renames=30%".into()];
144 let abbrev = hex_len
145 .map(|n| format!("--abbrev={}", n))
146 .unwrap_or_else(|| "--abbrev=40".into());
147 args.push(abbrev);
148 args.push("--full-index".into());
149 args.push(req.left.to_hex().to_string());
150 args.push(req.right.to_hex().to_string());
151 if let Some(paths) = &opts.paths
152 && !paths.is_empty()
153 {
154 args.push("--".into());
155 for p in paths {
156 args.push(share::to_string_lossy_owned(p));
157 }
158 }
159 let _ = opts;
160 share::git_cmd(CALLER, dir, env, None, args)
161}
162
163pub fn build_numstat_cmd(dir: &Path, env: Env, req: &DiffRequest, opts: &DiffOptions) -> Cmd {
164 let mut args: Vec<String> = vec!["diff".into(), "--numstat".into(), "-z".into()];
165 args.push(req.left.to_hex().to_string());
166 args.push(req.right.to_hex().to_string());
167 if let Some(paths) = &opts.paths
168 && !paths.is_empty()
169 {
170 args.push("--".into());
171 for p in paths {
172 args.push(share::to_string_lossy_owned(p));
173 }
174 }
175 share::git_cmd(CALLER, dir, env, None, args)
176}
177
178pub fn build_shortstat_cmd(dir: &Path, env: Env, req: &DiffRequest, opts: &DiffOptions) -> Cmd {
179 let mut args: Vec<String> = vec!["diff".into(), "--shortstat".into()];
180 args.push(req.left.to_hex().to_string());
181 args.push(req.right.to_hex().to_string());
182 if let Some(paths) = &opts.paths
183 && !paths.is_empty()
184 {
185 args.push("--".into());
186 for p in paths {
187 args.push(share::to_string_lossy_owned(p));
188 }
189 }
190 let _ = opts;
191 share::git_cmd(CALLER, dir, env, None, args)
192}
193
194pub fn build_range_diff_cmd(
195 dir: &Path,
196
197 env: Env,
198 spec: &RangeDiffSpec,
199 _opts: &DiffOptions,
200 hex_len: Option<u8>,
201) -> Cmd {
202 let mut args: Vec<String> = vec!["range-diff".into(), "--no-color".into()];
203 let abbrev = hex_len
204 .map(|n| format!("--abbrev={}", n))
205 .unwrap_or_else(|| "--abbrev=40".into());
206 args.push(abbrev);
207 match spec {
208 RangeDiffSpec::Range(r1, r2) => {
209 args.push(r1.clone());
210 args.push(r2.clone());
211 }
212 RangeDiffSpec::RevisionRange(r1, r2) => {
213 args.push(format!("{}...{}", r1, r2));
214 }
215 RangeDiffSpec::BaseWithRevisions(base, r1, r2) => {
216 args.push(base.clone());
217 args.push(r1.clone());
218 args.push(r2.clone());
219 }
220 }
221 share::git_cmd(CALLER, dir, env, None, args)
222}
223
224pub fn build_find_changed_paths_cmd(
225 dir: &Path,
226
227 env: Env,
228 _requests: &[ChangedPathRequest],
229 filter_status: Option<char>,
230 detect_renames: Option<u8>,
231 merge_parents: bool,
232) -> Cmd {
233 let mut args: Vec<String> = vec![
234 "diff-tree".into(),
235 "-z".into(),
236 "--stdin".into(),
237 "-r".into(),
238 "--root".into(),
239 "--no-commit-id".into(),
240 ];
241 if let Some(ch) = filter_status {
242 args.push(format!("--diff-filter={}", ch));
243 } else {
244 args.push("--diff-filter=AMDTCR".into());
245 }
246 match detect_renames {
247 Some(threshold) => args.push(format!("--find-renames={}%", threshold)),
248 None => args.push("--no-renames".into()),
249 }
250 if merge_parents {
251 args.push("-m".into());
252 }
253 share::git_cmd(CALLER, dir, env, None, args)
254}
255
256pub fn build_diff_tree_stdin(requests: &[ChangedPathRequest]) -> Vec<u8> {
257 let mut buf = Vec::new();
258 for r in requests {
259 match r {
260 ChangedPathRequest::Commit(oid) => {
261 buf.extend_from_slice(oid.to_string().as_bytes());
262 buf.push(b'\n');
263 }
264 ChangedPathRequest::TreePair(base, head) => {
265 buf.extend_from_slice(head.to_string().as_bytes());
266 buf.push(b' ');
267 buf.extend_from_slice(base.to_string().as_bytes());
268 buf.push(b'\n');
269 }
270 }
271 }
272 buf
273}
274
275pub fn classify_stderr(stderr: &str, left: &str, right: &str) -> BabyError {
276 if stderr.contains("fatal: bad revision") || stderr.contains("bad revision") {
277 return BabyError::RevisionNotFound(format!("{}..{}", left, right));
278 }
279 BabyError::Custom(format!("git diff failed: {}", stderr.trim()))
280}
281
282pub fn classify_cmd_error(err: CmdError, left: &str, right: &str) -> BabyError {
283 match err {
284 CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, left, right),
285 CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
286 other => BabyError::from(other),
287 }
288}
289
290const ZERO_OID_HEX: &str = "0000000000000000000000000000000000000000";
291
292type ParsedRawHeader = (u32, u32, Option<ObjectId>, Option<ObjectId>, DiffStatus);
293
294fn parse_raw_header(head: &str, abbrev: usize) -> Result<ParsedRawHeader, BabyError> {
295 let mut fields = head.splitn(5, ' ');
296 let old_mode_raw = fields.next().unwrap_or("").trim_start_matches(':');
297 let new_mode_raw = fields.next().unwrap_or("");
298 let old_oid_raw = fields.next().unwrap_or("");
299 let new_oid_raw = fields.next().unwrap_or("");
300 let status_raw = fields.next().unwrap_or("");
301 if old_mode_raw.is_empty()
302 || new_mode_raw.is_empty()
303 || old_oid_raw.is_empty()
304 || new_oid_raw.is_empty()
305 || status_raw.is_empty()
306 {
307 return Err(BabyError::Custom(format!(
308 "raw diff line too short: `{}`",
309 head
310 )));
311 }
312 let status_ch = status_raw
313 .chars()
314 .next()
315 .ok_or_else(|| BabyError::Custom(format!("raw diff status missing: `{}`", head)))?;
316 let status = DiffStatus::from_char(status_ch)
317 .ok_or_else(|| BabyError::Custom(format!("unknown diff status `{}`", status_ch)))?;
318 let old_mode = share::parse_git_mode(old_mode_raw)?;
319 let new_mode = share::parse_git_mode(new_mode_raw)?;
320 let old_oid = if old_oid_raw == ZERO_OID_HEX || old_oid_raw.len() < abbrev {
321 None
322 } else {
323 Some(parse_oid_prefix(old_oid_raw)?)
324 };
325 let new_oid = if new_oid_raw == ZERO_OID_HEX || new_oid_raw.len() < abbrev {
326 None
327 } else {
328 Some(parse_oid_prefix(new_oid_raw)?)
329 };
330 Ok((old_mode, new_mode, old_oid, new_oid, status))
331}
332
333pub fn parse_raw_diff_line(line: &str, abbrev: usize) -> Result<ChangedPath, BabyError> {
334 let (head, rest) = line
335 .split_once('\t')
336 .ok_or_else(|| BabyError::Custom(format!("raw diff line too short: `{}`", line)))?;
337 let (old_mode, new_mode, old_oid, new_oid, status) = parse_raw_header(head, abbrev)?;
338 let (old_path, new_path) = match status {
339 DiffStatus::Renamed | DiffStatus::Copied => rest
340 .split_once('\t')
341 .map(|(o, n)| (Some(PathBuf::from(o)), PathBuf::from(n)))
342 .unwrap_or((None, PathBuf::from(rest))),
343 _ => (None, PathBuf::from(rest)),
344 };
345 Ok(ChangedPath {
346 old_path,
347 new_path,
348 old_mode,
349 new_mode,
350 old_oid,
351 new_oid,
352 status,
353 })
354}
355
356pub fn parse_diff_tree_z_stream(
357 stdout: &[u8],
358 abbrev: usize,
359) -> Result<Vec<ChangedPath>, BabyError> {
360 let chunks: Vec<&[u8]> = stdout.split(|b| *b == 0).collect();
361 let mut out = Vec::new();
362 let mut i = 0;
363 while i < chunks.len() {
364 if chunks[i].is_empty() {
365 i += 1;
366 continue;
367 }
368 let head = String::from_utf8_lossy(chunks[i]).into_owned();
369 if !head.starts_with(':') {
370 i += 1;
371 continue;
372 }
373 let (old_mode, new_mode, old_oid, new_oid, status) = parse_raw_header(&head, abbrev)?;
374 i += 1;
375 let first = chunks
376 .get(i)
377 .map(|c| String::from_utf8_lossy(c).into_owned());
378 i += 1;
379 let second = if matches!(status, DiffStatus::Renamed | DiffStatus::Copied) {
380 let s = chunks
381 .get(i)
382 .map(|c| String::from_utf8_lossy(c).into_owned());
383 i += 1;
384 s
385 } else {
386 None
387 };
388 let (old_path, new_path) = match status {
389 DiffStatus::Renamed | DiffStatus::Copied => (
390 first.filter(|s| !s.is_empty()).map(PathBuf::from),
391 PathBuf::from(second.filter(|s| !s.is_empty()).unwrap_or_default()),
392 ),
393 _ => (
394 None,
395 PathBuf::from(first.filter(|s| !s.is_empty()).unwrap_or_default()),
396 ),
397 };
398 out.push(ChangedPath {
399 old_path,
400 new_path,
401 old_mode,
402 new_mode,
403 old_oid,
404 new_oid,
405 status,
406 });
407 }
408 Ok(out)
409}
410
411pub fn parse_oid_prefix(hex: &str) -> Result<ObjectId, BabyError> {
412 ObjectId::from_hex(hex.as_bytes())
413 .map_err(|source| BabyError::Custom(format!("invalid oid `{}`: {}", hex, source)))
414}
415
416pub fn parse_numstat_output(data: &[u8]) -> Result<Vec<NumStat>, BabyError> {
417 let mut stats = Vec::new();
418 let s = String::from_utf8_lossy(data);
419 for chunk in s.split('\0') {
420 if chunk.is_empty() {
421 continue;
422 }
423 let mut parts = chunk.splitn(3, '\t');
424 let adds_raw = parts.next().unwrap_or("");
425 let dels_raw = parts.next().unwrap_or("");
426 let last = parts.next().unwrap_or("");
427 let mut iter = last.splitn(2, '\t');
428 let first = iter.next().unwrap_or("");
429 let second = iter.next();
430 let mut stat = NumStat {
431 additions: parse_numstat_number(adds_raw)?,
432 deletions: parse_numstat_number(dels_raw)?,
433 path: PathBuf::new(),
434 old_path: None,
435 };
436 match second {
437 Some(p) => {
438 stat.old_path = Some(PathBuf::from(first));
439 stat.path = PathBuf::from(p);
440 }
441 None => {
442 stat.path = PathBuf::from(first);
443 }
444 }
445 stats.push(stat);
446 }
447 Ok(stats)
448}
449
450fn parse_numstat_number(s: &str) -> Result<u64, BabyError> {
451 if s == "-" {
452 return Ok(0);
453 }
454 s.parse::<u64>()
455 .map_err(|e| BabyError::Custom(format!("invalid numstat number `{}`: {}", s, e)))
456}
457
458pub fn parse_shortstat_output(data: &[u8]) -> Result<ShortStat, BabyError> {
459 let s = String::from_utf8_lossy(data);
460 let mut stat = ShortStat::default();
461 let mut rest = s.trim();
462 if let Some(idx) = rest.find("files changed") {
463 stat.files_changed = parse_u64_before(rest, idx)?;
464 rest = &rest[idx + "files changed".len()..];
465 }
466 if let Some(idx) = rest.find("insertions") {
467 stat.additions = parse_u64_before(rest, idx)?;
468 rest = &rest[idx + "insertions".len()..];
469 }
470 if let Some(idx) = rest.find("deletions") {
471 stat.deletions = parse_u64_before(rest, idx)?;
472 }
473 Ok(stat)
474}
475
476fn parse_u64_before(s: &str, before: usize) -> Result<u64, BabyError> {
477 let prefix = &s[..before];
478 let trimmed = prefix.trim().trim_end_matches(',').trim();
479 trimmed
480 .parse::<u64>()
481 .map_err(|e| BabyError::Custom(format!("invalid number `{}`: {}", trimmed, e)))
482}
483
484pub fn parse_commit_diff_output(
485 data: &[u8],
486 abbrev: usize,
487) -> Result<Vec<ChangedPathDiff>, BabyError> {
488 if data.is_empty() {
489 return Ok(Vec::new());
490 }
491
492 let diff_git_prefix = b"diff --git ";
493 let raw_end = data
494 .windows(diff_git_prefix.len())
495 .position(|w| w == diff_git_prefix)
496 .unwrap_or(data.len());
497
498 let raw_part = &data[..raw_end];
499 let patch_part = &data[raw_end..];
500
501 let mut entries: Vec<ChangedPathDiff> = Vec::new();
502 let mut index_map: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
503
504 let raw_str = String::from_utf8_lossy(raw_part);
505 for line in raw_str.lines() {
506 let line = line.trim();
507 if line.is_empty() || !line.starts_with(':') {
508 continue;
509 }
510 if let Ok(cp) = parse_raw_diff_line(line, abbrev) {
511 let key = match &cp.old_path {
512 Some(old) => format!(
513 "a/{} b/{}",
514 old.to_string_lossy(),
515 cp.new_path.to_string_lossy()
516 ),
517 None => format!("a/{0} b/{0}", cp.new_path.to_string_lossy()),
518 };
519 let idx = entries.len();
520 index_map.insert(key, idx);
521 entries.push(ChangedPathDiff {
522 path: cp,
523 lines_added: 0,
524 lines_removed: 0,
525 binary: false,
526 patch: None,
527 });
528 }
529 }
530
531 if !patch_part.is_empty() {
532 let mut cur_pos = 0;
533 while cur_pos < patch_part.len() {
534 let next_sep = patch_part[cur_pos + 1..]
535 .windows(b"\ndiff --git ".len())
536 .position(|w| w == b"\ndiff --git ")
537 .map(|p| cur_pos + 1 + p);
538
539 let block_end = next_sep.unwrap_or(patch_part.len());
540 let block = &patch_part[cur_pos..block_end];
541 cur_pos = block_end + 1;
542
543 if let Some(first_line_end) = block.iter().position(|&b| b == b'\n') {
544 let header = std::str::from_utf8(&block[..first_line_end]).unwrap_or("");
545 if let Some(tail) = header.strip_prefix("diff --git ")
546 && let Some(&idx) = index_map.get(tail)
547 {
548 apply_patch_slice(&mut entries[idx], block);
549 }
550 }
551 }
552 }
553
554 Ok(entries)
555}
556
557const MAX_SINGLE_PATCH_BYTES: usize = 256 * 1024;
558
559fn apply_patch_slice(e: &mut ChangedPathDiff, slice: &[u8]) {
560 let mut added = 0u64;
561 let mut removed = 0u64;
562 let mut binary = false;
563
564 for line in slice.split(|&b| b == b'\n') {
565 if line.starts_with(b"Binary files") {
566 binary = true;
567 } else if line.starts_with(b"+") && !line.starts_with(b"+++") {
568 added = added.saturating_add(1);
569 } else if line.starts_with(b"-") && !line.starts_with(b"---") {
570 removed = removed.saturating_add(1);
571 }
572 }
573
574 e.lines_added = added;
575 e.lines_removed = removed;
576 e.binary = binary;
577 if binary {
578 e.patch = None;
579 } else if slice.len() > MAX_SINGLE_PATCH_BYTES {
580 e.patch = Some(slice[..MAX_SINGLE_PATCH_BYTES].to_vec());
581 } else {
582 e.patch = Some(slice.to_vec());
583 }
584}
585
586pub fn parse_diff_tree_status_line(line: &str, abbrev: usize) -> Result<ChangedPath, BabyError> {
587 parse_raw_diff_line(line, abbrev)
588}
Added lib/gitbaby/src/diff/mod.rs lib/gitbaby/src/diff/mod.rs +8 −0 View lib/gitbaby/src/diff/mod.rs
@@ -0,0 +1,8 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{
6 ChangedPath, ChangedPathDiff, ChangedPathRequest, DiffOptions, DiffRequest, DiffStatus,
7 NumStat, RangeDiffSpec, ShortStat, WhitespaceMode,
8};
Added lib/gitbaby/src/diff/types.rs lib/gitbaby/src/diff/types.rs +97 −0 View lib/gitbaby/src/diff/types.rs
@@ -0,0 +1,97 @@
1use std::path::PathBuf;
2
3use gix::ObjectId;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub struct DiffRequest {
8 pub left: ObjectId,
9 pub right: ObjectId,
10}
11
12#[derive(Debug, Default, Clone, Serialize, Deserialize)]
13pub struct DiffOptions {
14 pub paths: Option<Vec<PathBuf>>,
15 pub ignore_whitespace: Option<WhitespaceMode>,
16 pub detect_renames: Option<u8>,
17 pub word_diff: bool,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum WhitespaceMode {
22 None,
23 IgnoreSpaceChange,
24 IgnoreAllSpace,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28pub enum DiffStatus {
29 Added,
30 Deleted,
31 Modified,
32 TypeChanged,
33 Renamed,
34 Copied,
35}
36
37impl DiffStatus {
38 pub fn from_char(ch: char) -> Option<Self> {
39 match ch {
40 'A' => Some(Self::Added),
41 'D' => Some(Self::Deleted),
42 'M' => Some(Self::Modified),
43 'T' => Some(Self::TypeChanged),
44 'R' => Some(Self::Renamed),
45 'C' => Some(Self::Copied),
46 _ => None,
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct ChangedPath {
53 pub old_path: Option<PathBuf>,
54 pub new_path: PathBuf,
55 pub old_mode: u32,
56 pub new_mode: u32,
57 pub old_oid: Option<ObjectId>,
58 pub new_oid: Option<ObjectId>,
59 pub status: DiffStatus,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct ChangedPathDiff {
64 pub path: ChangedPath,
65 pub lines_added: u64,
66 pub lines_removed: u64,
67 pub binary: bool,
68 pub patch: Option<Vec<u8>>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct NumStat {
73 pub additions: u64,
74 pub deletions: u64,
75 pub path: PathBuf,
76 pub old_path: Option<PathBuf>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
80pub struct ShortStat {
81 pub files_changed: u64,
82 pub additions: u64,
83 pub deletions: u64,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub enum ChangedPathRequest {
88 Commit(ObjectId),
89 TreePair(ObjectId, ObjectId),
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub enum RangeDiffSpec {
94 Range(String, String),
95 RevisionRange(String, String),
96 BaseWithRevisions(String, String, String),
97}
Added lib/gitbaby/src/diff/usecase.rs lib/gitbaby/src/diff/usecase.rs +448 −0 View lib/gitbaby/src/diff/usecase.rs
@@ -0,0 +1,448 @@
1use std::path::Path;
2
3use gix::ObjectId;
4use tokio::io::{AsyncBufReadExt, AsyncReadExt};
5
6use crate::GitBaby;
7use crate::command::error::CmdError;
8use crate::diff::helper;
9use crate::diff::types::{
10 ChangedPath, ChangedPathDiff, ChangedPathRequest, DiffOptions, DiffRequest, NumStat,
11 RangeDiffSpec, ShortStat,
12};
13use crate::error::BabyError;
14
15impl GitBaby {
16 pub async fn raw_diff(
17 &self,
18 req: &DiffRequest,
19 hex_len: Option<u8>,
20 ) -> Result<String, BabyError> {
21 let opts = DiffOptions::default();
22 let mut cmd = self
23 .spawn_diff_cmd(req, hex_len, &opts, helper::build_raw_diff_cmd)
24 .await?;
25 run_capture(&mut cmd, &req.left.to_string(), &req.right.to_string()).await
26 }
27
28 pub async fn raw_diff_stream<F>(
29 &self,
30 req: &DiffRequest,
31 hex_len: Option<u8>,
32 #[allow(unused_mut)] mut callback: F,
33 ) -> Result<(), BabyError>
34 where
35 F: FnMut(&[u8]) + Send,
36 {
37 let opts = DiffOptions::default();
38 let mut cmd = self
39 .spawn_diff_cmd(req, hex_len, &opts, helper::build_raw_diff_cmd)
40 .await?;
41 run_stream(
42 &mut cmd,
43 &req.left.to_string(),
44 &req.right.to_string(),
45 callback,
46 )
47 .await
48 }
49
50 pub async fn patch_diff(
51 &self,
52 req: &DiffRequest,
53 hex_len: Option<u8>,
54 ) -> Result<String, BabyError> {
55 let opts = DiffOptions::default();
56 let mut cmd = self
57 .spawn_diff_cmd(req, hex_len, &opts, helper::build_patch_diff_cmd)
58 .await?;
59 run_capture(&mut cmd, &req.left.to_string(), &req.right.to_string()).await
60 }
61
62 pub async fn patch_diff_stream<F>(
63 &self,
64 req: &DiffRequest,
65 hex_len: Option<u8>,
66 #[allow(unused_mut)] mut callback: F,
67 ) -> Result<(), BabyError>
68 where
69 F: FnMut(&[u8]) + Send,
70 {
71 let opts = DiffOptions::default();
72 let mut cmd = self
73 .spawn_diff_cmd(req, hex_len, &opts, helper::build_patch_diff_cmd)
74 .await?;
75 run_stream(
76 &mut cmd,
77 &req.left.to_string(),
78 &req.right.to_string(),
79 callback,
80 )
81 .await
82 }
83
84 pub async fn commit_delta(
85 &self,
86 req: &DiffRequest,
87 opts: &DiffOptions,
88 hex_len: Option<u8>,
89 ) -> Result<Vec<ChangedPath>, BabyError> {
90 if let Some(paths) = &opts.paths {
91 helper::validate_paths(paths)?;
92 }
93 let hex_len = match hex_len {
94 Some(n) => n,
95 None => self.repo_hex_len().await?,
96 };
97 let mut cmd = self
98 .spawn_diff_cmd(req, Some(hex_len), opts, helper::build_commit_delta_cmd)
99 .await?;
100 let output = run_output(&mut cmd, &req.left.to_string(), &req.right.to_string()).await?;
101 let abbrev = hex_len as usize;
102 let mut paths: Vec<ChangedPath> = Vec::new();
103 for line in String::from_utf8_lossy(&output.stdout).lines() {
104 if line.is_empty() {
105 continue;
106 }
107 paths.push(helper::parse_raw_diff_line(line, abbrev)?);
108 }
109 Ok(paths)
110 }
111
112 pub async fn commit_diff(
113 &self,
114 req: &DiffRequest,
115 opts: &DiffOptions,
116 hex_len: Option<u8>,
117 ) -> Result<Vec<ChangedPathDiff>, BabyError> {
118 if let Some(paths) = &opts.paths {
119 helper::validate_paths(paths)?;
120 }
121 let hex_len = match hex_len {
122 Some(n) => n,
123 None => self.repo_hex_len().await?,
124 };
125 let mut cmd = self
126 .spawn_diff_cmd(req, Some(hex_len), opts, helper::build_commit_diff_cmd)
127 .await?;
128 let output = run_output(&mut cmd, &req.left.to_string(), &req.right.to_string()).await?;
129 let abbrev = hex_len as usize;
130 helper::parse_commit_diff_output(&output.stdout, abbrev)
131 }
132
133 pub async fn find_changed_paths(
134 &self,
135 requests: Vec<ChangedPathRequest>,
136 filter_status: Option<char>,
137 detect_renames: Option<u8>,
138 merge_parents: bool,
139 ) -> Result<Vec<ChangedPath>, BabyError> {
140 let mut cmd = self
141 .spawn_diff_state_cmd(&requests, filter_status, detect_renames, merge_parents)
142 .await?;
143 let payload = helper::build_diff_tree_stdin(&requests);
144 let output = match cmd.feed(&payload).await {
145 Ok(()) => match cmd.run().await {
146 Ok(o) => o,
147 Err(CmdError::NonZeroExit { stderr, .. }) => {
148 return Err(helper::classify_stderr(
149 &stderr,
150 &requests.first().map_or_else(String::new, |r| match r {
151 ChangedPathRequest::Commit(o) => o.to_hex().to_string(),
152 ChangedPathRequest::TreePair(l, _) => l.to_hex().to_string(),
153 }),
154 "",
155 ));
156 }
157 Err(e) => return Err(BabyError::from(e)),
158 },
159 Err(e) => return Err(BabyError::from(e)),
160 };
161 let abbrev = self.repo_hex_len().await? as usize;
162 helper::parse_diff_tree_z_stream(&output.stdout, abbrev)
163 }
164
165 pub async fn diff_stats(
166 &self,
167 req: &DiffRequest,
168 opts: &DiffOptions,
169 ) -> Result<Vec<NumStat>, BabyError> {
170 if let Some(paths) = &opts.paths {
171 helper::validate_paths(paths)?;
172 }
173 let mut cmd = self
174 .spawn_diff_cmd_no_hex(req, opts, helper::build_numstat_cmd)
175 .await?;
176 let output = run_output(&mut cmd, &req.left.to_string(), &req.right.to_string()).await?;
177 helper::parse_numstat_output(&output.stdout)
178 }
179
180 pub async fn shortstat(
181 &self,
182 req: &DiffRequest,
183 opts: &DiffOptions,
184 ) -> Result<ShortStat, BabyError> {
185 if let Some(paths) = &opts.paths {
186 helper::validate_paths(paths)?;
187 }
188 let mut cmd = self
189 .spawn_diff_cmd_no_hex(req, opts, helper::build_shortstat_cmd)
190 .await?;
191 let output = run_output(&mut cmd, &req.left.to_string(), &req.right.to_string()).await?;
192 helper::parse_shortstat_output(&output.stdout)
193 }
194
195 pub async fn range_diff(
196 &self,
197 spec: &RangeDiffSpec,
198 opts: &DiffOptions,
199 hex_len: Option<u8>,
200 ) -> Result<String, BabyError> {
201 let mut cmd = self.spawn_range_diff_cmd(spec, opts, hex_len).await?;
202 let (left, right) = match spec {
203 RangeDiffSpec::Range(r1, r2) | RangeDiffSpec::RevisionRange(r1, r2) => {
204 (r1.clone(), r2.clone())
205 }
206 RangeDiffSpec::BaseWithRevisions(_, r1, r2) => (r1.clone(), r2.clone()),
207 };
208 run_capture(&mut cmd, &left, &right).await
209 }
210
211 async fn spawn_diff_cmd<F>(
212 &self,
213 req: &DiffRequest,
214 hex_len: Option<u8>,
215 opts: &DiffOptions,
216 builder: F,
217 ) -> Result<crate::command::cmd::Cmd, BabyError>
218 where
219 F: FnOnce(
220 &Path,
221 crate::command::env::Env,
222 &DiffRequest,
223 &DiffOptions,
224 Option<u8>,
225 ) -> crate::command::cmd::Cmd,
226 {
227 let dir = self
228 .facade
229 .git_repo_dir()
230 .await
231 .map_err(crate::share::facade_error)?;
232 let alternates = self
233 .facade
234 .git_alternate_object_directories()
235 .await
236 .map_err(crate::share::facade_error)?;
237 let resolved = match hex_len {
238 Some(n) => n,
239 None => self.repo_hex_len().await?,
240 };
241 let env = crate::share::build_env_safe(&alternates);
242 Ok(builder(&dir, env, req, opts, Some(resolved)))
243 }
244
245 async fn spawn_diff_cmd_no_hex<F>(
246 &self,
247 req: &DiffRequest,
248 opts: &DiffOptions,
249 builder: F,
250 ) -> Result<crate::command::cmd::Cmd, BabyError>
251 where
252 F: FnOnce(
253 &Path,
254 crate::command::env::Env,
255 &DiffRequest,
256 &DiffOptions,
257 ) -> crate::command::cmd::Cmd,
258 {
259 let dir = self
260 .facade
261 .git_repo_dir()
262 .await
263 .map_err(crate::share::facade_error)?;
264 let alternates = self
265 .facade
266 .git_alternate_object_directories()
267 .await
268 .map_err(crate::share::facade_error)?;
269 let env = crate::share::build_env_safe(&alternates);
270 Ok(builder(&dir, env, req, opts))
271 }
272
273 async fn spawn_diff_state_cmd(
274 &self,
275 requests: &[ChangedPathRequest],
276 filter_status: Option<char>,
277 detect_renames: Option<u8>,
278 merge_parents: bool,
279 ) -> Result<crate::command::cmd::Cmd, BabyError> {
280 let dir = self
281 .facade
282 .git_repo_dir()
283 .await
284 .map_err(crate::share::facade_error)?;
285 let alternates = self
286 .facade
287 .git_alternate_object_directories()
288 .await
289 .map_err(crate::share::facade_error)?;
290 let env = crate::share::build_env_safe(&alternates);
291 Ok(helper::build_find_changed_paths_cmd(
292 &dir,
293 env,
294 requests,
295 filter_status,
296 detect_renames,
297 merge_parents,
298 ))
299 }
300
301 async fn spawn_range_diff_cmd(
302 &self,
303 spec: &RangeDiffSpec,
304 opts: &DiffOptions,
305 hex_len: Option<u8>,
306 ) -> Result<crate::command::cmd::Cmd, BabyError> {
307 let dir = self
308 .facade
309 .git_repo_dir()
310 .await
311 .map_err(crate::share::facade_error)?;
312 let alternates = self
313 .facade
314 .git_alternate_object_directories()
315 .await
316 .map_err(crate::share::facade_error)?;
317 let resolved = match hex_len {
318 Some(n) => n,
319 None => self.repo_hex_len().await?,
320 };
321 let env = crate::share::build_env_safe(&alternates);
322 Ok(helper::build_range_diff_cmd(
323 &dir,
324 env,
325 spec,
326 opts,
327 Some(resolved),
328 ))
329 }
330
331 async fn repo_hex_len(&self) -> Result<u8, BabyError> {
332 let repo = self
333 .facade
334 .gix_repo()
335 .await
336 .map_err(crate::share::facade_error)?;
337 Ok(repo.object_hash().len_in_hex() as u8)
338 }
339}
340
341async fn run_capture(
342 cmd: &mut crate::command::cmd::Cmd,
343 left: &str,
344 right: &str,
345) -> Result<String, BabyError> {
346 let output = match cmd.run().await {
347 Ok(o) => o,
348 Err(CmdError::NonZeroExit { ref stderr, .. }) => {
349 return Err(helper::classify_stderr(stderr, left, right));
350 }
351 Err(e) => return Err(helper::classify_cmd_error(e, left, right)),
352 };
353 if !output.status.success() {
354 return Err(helper::classify_stderr(
355 &String::from_utf8_lossy(&output.stderr),
356 left,
357 right,
358 ));
359 }
360 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
361}
362
363async fn run_output(
364 cmd: &mut crate::command::cmd::Cmd,
365 left: &str,
366 right: &str,
367) -> Result<crate::command::cmd::CmdOutput, BabyError> {
368 let output = match cmd.run().await {
369 Ok(o) => o,
370 Err(CmdError::NonZeroExit { ref stderr, .. }) => {
371 return Err(helper::classify_stderr(stderr, left, right));
372 }
373 Err(e) => return Err(helper::classify_cmd_error(e, left, right)),
374 };
375 if !output.status.success() {
376 return Err(helper::classify_stderr(
377 &String::from_utf8_lossy(&output.stderr),
378 left,
379 right,
380 ));
381 }
382 Ok(output)
383}
384
385async fn run_stream<F>(
386 cmd: &mut crate::command::cmd::Cmd,
387 left: &str,
388 right: &str,
389 mut callback: F,
390) -> Result<(), BabyError>
391where
392 F: FnMut(&[u8]) + Send,
393{
394 if let Err(e) = cmd.spawn().await {
395 return Err(helper::classify_cmd_error(e, left, right));
396 }
397 let stdout = std::mem::replace(&mut cmd.stdout, Box::new(tokio::io::empty()));
398 let stderr = std::mem::replace(&mut cmd.stderr, Box::new(tokio::io::empty()));
399
400 // Drain stderr concurrently so a chatty child cannot fill the pipe buffer
401 // and deadlock while we consume stdout.
402 let stderr_task = tokio::spawn(async move {
403 let mut stderr = stderr;
404 let mut sbuf = Vec::new();
405 let _ = stderr.read_to_end(&mut sbuf).await;
406 sbuf
407 });
408
409 let mut reader = tokio::io::BufReader::new(stdout);
410 let mut buf = Vec::with_capacity(8192);
411 loop {
412 buf.clear();
413 let n = match reader.read_until(b'\n', &mut buf).await {
414 Ok(n) => n,
415 Err(source) => {
416 return Err(BabyError::Custom(format!("stream read failed: {}", source)));
417 }
418 };
419 if n == 0 {
420 break;
421 }
422 callback(&buf);
423 }
424 let status = match cmd.wait().await {
425 Ok(s) => s,
426 Err(e) => return Err(helper::classify_cmd_error(e, left, right)),
427 };
428 let sbuf = match stderr_task.await {
429 Ok(v) => v,
430 Err(e) => {
431 return Err(BabyError::Custom(format!(
432 "stderr drain task failed: {}",
433 e
434 )));
435 }
436 };
437 if !status.success() {
438 return Err(helper::classify_stderr(
439 &String::from_utf8_lossy(&sbuf),
440 left,
441 right,
442 ));
443 }
444 Ok(())
445}
446
447#[allow(dead_code)]
448fn _oid_marker(_o: ObjectId) {}
Added lib/gitbaby/src/merge/helper.rs lib/gitbaby/src/merge/helper.rs +32 −0 View lib/gitbaby/src/merge/helper.rs
@@ -0,0 +1,32 @@
1use std::path::Path;
2
3use crate::command::cmd::Cmd;
4use crate::command::env::Env;
5use crate::command::error::CmdError;
6use crate::error::BabyError;
7use crate::share;
8
9pub const CALLER: &str = "gitbaby::merge";
10
11pub fn build_merge_base_cmd(dir: &Path, env: Env, one: &str, two: &str) -> Cmd {
12 let args: Vec<String> = vec!["merge-base".into(), one.into(), two.into()];
13 share::git_cmd(CALLER, dir, env, None, args)
14}
15
16pub fn classify_stderr(stderr: &str, one: &str, two: &str) -> BabyError {
17 if stderr.trim().is_empty() {
18 return BabyError::MergeBaseNotFound(format!("{}..{}", one, two));
19 }
20 if stderr.contains("fatal: bad revision") {
21 return BabyError::RevisionNotFound(format!("{}..{}", one, two));
22 }
23 BabyError::Custom(format!("git merge-base failed: {}", stderr.trim()))
24}
25
26pub fn classify_cmd_error(err: CmdError, one: &str, two: &str) -> BabyError {
27 match err {
28 CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, one, two),
29 CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
30 other => BabyError::from(other),
31 }
32}
Added lib/gitbaby/src/merge/mod.rs lib/gitbaby/src/merge/mod.rs +5 −0 View lib/gitbaby/src/merge/mod.rs
@@ -0,0 +1,5 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use crate::conflict::MergeTreeResult;
Added lib/gitbaby/src/merge/types.rs lib/gitbaby/src/merge/types.rs +7 −0 View lib/gitbaby/src/merge/types.rs
@@ -0,0 +1,7 @@
1use gix::ObjectId;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5pub struct MergeBaseResult {
6 pub oid: ObjectId,
7}
Added lib/gitbaby/src/merge/usecase.rs lib/gitbaby/src/merge/usecase.rs +55 −0 View lib/gitbaby/src/merge/usecase.rs
@@ -0,0 +1,55 @@
1use gix::ObjectId;
2
3use crate::GitBaby;
4use crate::command::error::CmdError;
5use crate::error::BabyError;
6use crate::merge::helper;
7
8impl GitBaby {
9 pub async fn merge_base(&self, one: &str, two: &str) -> Result<ObjectId, BabyError> {
10 crate::share::validate_revision(one)?;
11 crate::share::validate_revision(two)?;
12 let mut cmd = self.spawn_merge_base_cmd(one, two).await?;
13 let output = match cmd.run().await {
14 Ok(o) => o,
15 Err(CmdError::NonZeroExit { ref stderr, .. }) => {
16 return Err(helper::classify_stderr(stderr, one, two));
17 }
18 Err(e) => return Err(helper::classify_cmd_error(e, one, two)),
19 };
20 if !output.status.success() {
21 return Err(helper::classify_stderr(
22 &String::from_utf8_lossy(&output.stderr),
23 one,
24 two,
25 ));
26 }
27 let text = String::from_utf8_lossy(&output.stdout);
28 let hex = text.trim();
29 ObjectId::from_hex(hex.as_bytes()).map_err(|source| {
30 BabyError::Custom(format!(
31 "merge-base returned invalid oid `{}`: {}",
32 hex, source
33 ))
34 })
35 }
36
37 async fn spawn_merge_base_cmd(
38 &self,
39 one: &str,
40 two: &str,
41 ) -> Result<crate::command::cmd::Cmd, BabyError> {
42 let dir = self
43 .facade
44 .git_repo_dir()
45 .await
46 .map_err(crate::share::facade_error)?;
47 let alternates = self
48 .facade
49 .git_alternate_object_directories()
50 .await
51 .map_err(crate::share::facade_error)?;
52 let env = crate::share::build_env_safe(&alternates);
53 Ok(helper::build_merge_base_cmd(&dir, env, one, two))
54 }
55}
Added lib/gitbaby/src/refs/helper.rs lib/gitbaby/src/refs/helper.rs +270 −0 View lib/gitbaby/src/refs/helper.rs
@@ -0,0 +1,270 @@
1use std::path::Path;
2
3use crate::command::cmd::Cmd;
4use crate::command::env::Env;
5use crate::command::error::CmdError;
6use crate::error::BabyError;
7use crate::share::{git_cmd, validate_ref_name, validate_ref_pattern};
8
9use super::types::{ListRefsOptions, RefInfo, RefType, RefUpdate};
10
11pub const CALLER: &str = "gitbaby::refs";
12
13pub fn validate_list_refs_options(opts: &ListRefsOptions) -> Result<(), BabyError> {
14 for p in &opts.patterns {
15 if !p.starts_with("refs/") {
16 return Err(BabyError::InvalidRefName(p.clone()));
17 }
18 validate_ref_pattern(p)?;
19 }
20 for p in &opts.exclude_patterns {
21 if !p.starts_with("refs/") {
22 return Err(BabyError::InvalidRefName(p.clone()));
23 }
24 validate_ref_pattern(p)?;
25 }
26 Ok(())
27}
28
29pub fn validate_ref_update(update: &RefUpdate) -> Result<(), BabyError> {
30 validate_ref_name(&update.name)?;
31 if update.new_sha.is_null() && update.old_sha.is_none() {
32 return Err(BabyError::InvalidGitArg(format!(
33 "ref `{}` new_sha is the zero object id (delete) but old_sha is missing",
34 update.name
35 )));
36 }
37 Ok(())
38}
39
40pub fn build_list_refs_cmd(dir: &Path, env: Env, opts: &ListRefsOptions) -> Cmd {
41 let args = crate::share::build_for_each_ref_args(
42 &opts.patterns,
43 None,
44 opts.limit,
45 opts.start_after.as_deref(),
46 None,
47 &opts.exclude_patterns,
48 opts.peel_tags,
49 );
50 git_cmd(CALLER, dir, env, None, args)
51}
52
53pub fn build_find_refs_by_oid_cmd(dir: &Path, env: Env, oid: &str) -> Cmd {
54 let args = crate::share::build_for_each_ref_args(
55 &["refs".to_string()],
56 None,
57 None,
58 None,
59 Some(oid),
60 &[],
61 false,
62 );
63 git_cmd(CALLER, dir, env, None, args)
64}
65
66pub fn build_update_ref_cmd(
67 dir: &Path,
68 env: Env,
69 name: &str,
70 new_sha: &str,
71 old_sha: Option<&str>,
72 atomic: bool,
73) -> Cmd {
74 let mut args = Vec::new();
75 if atomic {
76 args.push("update-ref".to_string());
77 args.push("--stdin".to_string());
78 } else {
79 args.push("update-ref".to_string());
80 let mut a = vec![name.to_string(), new_sha.to_string()];
81 if let Some(o) = old_sha {
82 a.push(o.to_string());
83 }
84 args.extend(a);
85 }
86 git_cmd(CALLER, dir, env, None, args)
87}
88
89pub fn build_delete_ref_cmd(dir: &Path, env: Env, name: &str, atomic: bool) -> Cmd {
90 let args = if atomic {
91 vec!["update-ref".to_string(), "--stdin".to_string()]
92 } else {
93 vec![
94 "update-ref".to_string(),
95 "--no-deref".to_string(),
96 "-d".to_string(),
97 name.to_string(),
98 ]
99 };
100 git_cmd(CALLER, dir, env, None, args)
101}
102
103pub fn build_show_ref_cmd(dir: &Path, env: Env, name: &str) -> Cmd {
104 git_cmd(
105 CALLER,
106 dir,
107 env,
108 None,
109 vec![
110 "show-ref".to_string(),
111 "--verify".to_string(),
112 "--".to_string(),
113 name.to_string(),
114 ],
115 )
116}
117
118pub fn build_revspec_cmd(dir: &Path, env: Env, rev: &str) -> Cmd {
119 git_cmd(
120 CALLER,
121 dir,
122 env,
123 None,
124 vec![
125 "rev-parse".to_string(),
126 "--verify".to_string(),
127 rev.to_string(),
128 ],
129 )
130}
131
132pub fn classify_stderr(stderr: &str, ref_name: Option<&str>) -> BabyError {
133 let s = stderr.trim();
134 if let Some(name) = ref_name
135 && (s.contains("not a tree") || s.contains("Not a tree"))
136 {
137 return BabyError::RefNotFound(name.to_string());
138 }
139 if s.contains("ambiguous") && ref_name.is_some() {
140 return BabyError::AmbiguousRef {
141 query: ref_name.unwrap_or("").to_string(),
142 candidates: Vec::new(),
143 };
144 }
145 if s.contains("not found") || s.contains("Needed a single revision") {
146 if let Some(name) = ref_name {
147 return BabyError::RefNotFound(name.to_string());
148 }
149 return BabyError::RevisionNotFound(s.to_string());
150 }
151 BabyError::Spawn {
152 prog: "git".to_string(),
153 source: std::io::Error::other(s.to_string()),
154 stderr: s.to_string(),
155 }
156}
157
158pub fn classify_cmd_error(err: CmdError, ref_name: Option<&str>) -> BabyError {
159 match err {
160 CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, ref_name),
161 CmdError::Spawn { prog, source } => crate::share::classify_spawn_error(prog, source),
162 other => BabyError::from(other),
163 }
164}
165
166pub fn classify_for_each_ref_type(s: &str) -> RefType {
167 match s {
168 "tag" => RefType::Tag,
169 "commit" => RefType::Branch,
170 _ => RefType::Other,
171 }
172}
173
174pub fn parse_for_each_ref_line(line: &str, peel: bool) -> Result<RefInfo, BabyError> {
175 let mut parts = line.splitn(4, ' ');
176 let sha_hex = parts.next().unwrap_or("");
177 let kind = parts.next().unwrap_or("");
178 let name = match peel {
179 true => match parts.next() {
180 Some(n) => {
181 let peeled_hex = parts.next().unwrap_or("");
182 let sha = gix::ObjectId::from_hex(sha_hex.as_bytes()).map_err(|e| {
183 BabyError::Custom(format!("for-each-ref invalid oid `{}`: {}", sha_hex, e))
184 })?;
185 let peeled = if peeled_hex.is_empty() {
186 None
187 } else {
188 Some(gix::ObjectId::from_hex(peeled_hex.as_bytes()).map_err(|e| {
189 BabyError::Custom(format!(
190 "for-each-ref invalid peeled oid `{}`: {}",
191 peeled_hex, e
192 ))
193 })?)
194 };
195 return Ok(RefInfo {
196 name: n.to_string(),
197 ref_type: classify_for_each_ref_type(kind),
198 sha,
199 peeled,
200 });
201 }
202 None => {
203 return Err(BabyError::Custom(format!(
204 "for-each-ref malformed line: {}",
205 line
206 )));
207 }
208 },
209 false => match parts.next() {
210 Some(n) => n,
211 None => {
212 return Err(BabyError::Custom(format!(
213 "for-each-ref malformed line: {}",
214 line
215 )));
216 }
217 },
218 };
219 let sha = gix::ObjectId::from_hex(sha_hex.as_bytes())
220 .map_err(|e| BabyError::Custom(format!("for-each-ref invalid oid `{}`: {}", sha_hex, e)))?;
221 Ok(RefInfo {
222 name: name.to_string(),
223 ref_type: classify_for_each_ref_type(kind),
224 sha,
225 peeled: None,
226 })
227}
228
229pub fn parse_for_each_ref_output(stdout: &[u8], peel: bool) -> Result<Vec<RefInfo>, BabyError> {
230 let text = std::str::from_utf8(stdout)
231 .map_err(|e| BabyError::Custom(format!("for-each-ref non-utf8: {}", e)))?;
232 let mut out = Vec::new();
233 for line in text.lines() {
234 if line.is_empty() {
235 continue;
236 }
237 out.push(parse_for_each_ref_line(line, peel)?);
238 }
239 Ok(out)
240}
241
242pub fn encode_atomic_update(updates: &[RefUpdate]) -> Result<Vec<u8>, BabyError> {
243 let mut buf = Vec::new();
244 for u in updates {
245 validate_ref_update(u)?;
246 buf.extend_from_slice(b"update ");
247 buf.extend_from_slice(u.name.as_bytes());
248 buf.push(b' ');
249 buf.extend_from_slice(u.new_sha.to_string().as_bytes());
250 if let Some(o) = u.old_sha {
251 buf.push(b' ');
252 buf.extend_from_slice(o.to_string().as_bytes());
253 }
254 buf.push(b'\n');
255 }
256 buf.extend_from_slice(b"prepare\ncommit\n");
257 Ok(buf)
258}
259
260pub fn encode_atomic_delete(names: &[String]) -> Result<Vec<u8>, BabyError> {
261 let mut buf = Vec::new();
262 for n in names {
263 validate_ref_name(n)?;
264 buf.extend_from_slice(b"delete ");
265 buf.extend_from_slice(n.as_bytes());
266 buf.push(b'\n');
267 }
268 buf.extend_from_slice(b"prepare\ncommit\n");
269 Ok(buf)
270}
Added lib/gitbaby/src/refs/mod.rs lib/gitbaby/src/refs/mod.rs +5 −0 View lib/gitbaby/src/refs/mod.rs
@@ -0,0 +1,5 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{ListRefsOptions, RefInfo, RefType, RefUpdate};
Added lib/gitbaby/src/refs/types.rs lib/gitbaby/src/refs/types.rs +34 −0 View lib/gitbaby/src/refs/types.rs
@@ -0,0 +1,34 @@
1use gix::ObjectId;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5pub enum RefType {
6 Branch,
7 Tag,
8 Remote,
9 Other,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct RefInfo {
14 pub name: String,
15 pub ref_type: RefType,
16 pub sha: ObjectId,
17 pub peeled: Option<ObjectId>,
18}
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21pub struct ListRefsOptions {
22 pub patterns: Vec<String>,
23 pub exclude_patterns: Vec<String>,
24 pub peel_tags: bool,
25 pub limit: Option<u32>,
26 pub start_after: Option<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct RefUpdate {
31 pub name: String,
32 pub new_sha: ObjectId,
33 pub old_sha: Option<ObjectId>,
34}
Added lib/gitbaby/src/refs/usecase.rs lib/gitbaby/src/refs/usecase.rs +225 −0 View lib/gitbaby/src/refs/usecase.rs
@@ -0,0 +1,225 @@
1use gix::ObjectId;
2
3use crate::GitBaby;
4use crate::error::BabyError;
5use crate::refs::helper;
6use crate::refs::types::{ListRefsOptions, RefInfo, RefUpdate};
7use crate::share;
8
9impl GitBaby {
10 pub async fn list_refs(&self, opts: ListRefsOptions) -> Result<Vec<RefInfo>, BabyError> {
11 helper::validate_list_refs_options(&opts)?;
12 let mut cmd = self
13 .spawn_refs_cmd(|dir, env| helper::build_list_refs_cmd(dir, env, &opts))
14 .await?;
15 let output = cmd
16 .run()
17 .await
18 .map_err(|e| helper::classify_cmd_error(e, None))?;
19 if !output.status.success() {
20 return Err(helper::classify_stderr(
21 &String::from_utf8_lossy(&output.stderr),
22 None,
23 ));
24 }
25 helper::parse_for_each_ref_output(&output.stdout, opts.peel_tags)
26 }
27
28 pub async fn ref_exists(&self, name: &str) -> Result<bool, BabyError> {
29 share::validate_ref_name(name)?;
30 let mut cmd = self
31 .spawn_refs_cmd(|dir, env| helper::build_show_ref_cmd(dir, env, name))
32 .await?;
33 match cmd.run().await {
34 Ok(out) => Ok(out.status.success()),
35 Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => {
36 let s = stderr.trim();
37 if s.contains("not found") || s.contains("does not exist") || s.is_empty() {
38 Ok(false)
39 } else {
40 Err(helper::classify_stderr(s, Some(name)))
41 }
42 }
43 Err(e) => Err(helper::classify_cmd_error(e, Some(name))),
44 }
45 }
46
47 pub async fn resolve_revision(&self, rev: &str) -> Result<ObjectId, BabyError> {
48 share::validate_revision_non_empty(rev)?;
49 let mut cmd = self
50 .spawn_refs_cmd(|dir, env| helper::build_revspec_cmd(dir, env, rev))
51 .await?;
52 let output = cmd
53 .run()
54 .await
55 .map_err(|e| helper::classify_cmd_error(e, Some(rev)))?;
56 if !output.status.success() {
57 return Err(helper::classify_stderr(
58 &String::from_utf8_lossy(&output.stderr),
59 Some(rev),
60 ));
61 }
62 let text = String::from_utf8_lossy(&output.stdout);
63 let hex = text.trim();
64 gix::ObjectId::from_hex(hex.as_bytes()).map_err(|e| {
65 BabyError::Custom(format!("rev-parse returned invalid oid `{}`: {}", hex, e))
66 })
67 }
68
69 pub async fn update_ref(
70 &self,
71 name: &str,
72 new_sha: ObjectId,
73 old_sha: Option<ObjectId>,
74 ) -> Result<(), BabyError> {
75 let update = RefUpdate {
76 name: name.to_string(),
77 new_sha,
78 old_sha,
79 };
80 helper::validate_ref_update(&update)?;
81 let old_str = update.old_sha.map(|o| o.to_string());
82 let mut cmd = self
83 .spawn_refs_cmd(|dir, env| {
84 helper::build_update_ref_cmd(
85 dir,
86 env,
87 &update.name,
88 &update.new_sha.to_string(),
89 old_str.as_deref(),
90 false,
91 )
92 })
93 .await?;
94 let output = cmd
95 .run()
96 .await
97 .map_err(|e| helper::classify_cmd_error(e, Some(&update.name)))?;
98 if !output.status.success() {
99 return Err(helper::classify_stderr(
100 &String::from_utf8_lossy(&output.stderr),
101 Some(&update.name),
102 ));
103 }
104 let _ = crate::cache::invalidation::hook_ref_updated(self, &update.name).await;
105 Ok(())
106 }
107
108 pub async fn delete_ref(&self, name: &str) -> Result<(), BabyError> {
109 share::validate_ref_name(name)?;
110 let mut cmd = self
111 .spawn_refs_cmd(|dir, env| helper::build_delete_ref_cmd(dir, env, name, false))
112 .await?;
113 let output = cmd
114 .run()
115 .await
116 .map_err(|e| helper::classify_cmd_error(e, Some(name)))?;
117 if !output.status.success() {
118 return Err(helper::classify_stderr(
119 &String::from_utf8_lossy(&output.stderr),
120 Some(name),
121 ));
122 }
123 let _ = crate::cache::invalidation::hook_ref_updated(self, name).await;
124 Ok(())
125 }
126
127 pub async fn find_refs_by_oid(&self, oid: ObjectId) -> Result<Vec<RefInfo>, BabyError> {
128 let mut cmd = self
129 .spawn_refs_cmd(|dir, env| {
130 helper::build_find_refs_by_oid_cmd(dir, env, &oid.to_string())
131 })
132 .await?;
133 let output = cmd
134 .run()
135 .await
136 .map_err(|e| helper::classify_cmd_error(e, None))?;
137 if !output.status.success() {
138 return Err(helper::classify_stderr(
139 &String::from_utf8_lossy(&output.stderr),
140 None,
141 ));
142 }
143 helper::parse_for_each_ref_output(&output.stdout, false)
144 }
145
146 pub async fn update_refs_atomic(&self, updates: Vec<RefUpdate>) -> Result<(), BabyError> {
147 if updates.is_empty() {
148 return Err(BabyError::Custom(
149 "update_refs_atomic requires at least one update".to_string(),
150 ));
151 }
152 for u in &updates {
153 helper::validate_ref_update(u)?;
154 }
155 let stdin = helper::encode_atomic_update(&updates)?;
156 let mut cmd = self
157 .spawn_refs_cmd(|dir, env| helper::build_update_ref_cmd(dir, env, "", "", None, true))
158 .await?;
159 if let Err(e) = cmd.feed(&stdin).await {
160 return Err(helper::classify_cmd_error(e, None));
161 }
162 let output = cmd
163 .run_soft()
164 .await
165 .map_err(|e| helper::classify_cmd_error(e, None))?;
166 if !output.status.success() {
167 return Err(BabyError::RefUpdateRejected(
168 String::from_utf8_lossy(&output.stderr).into_owned(),
169 ));
170 }
171 for u in &updates {
172 let _ = crate::cache::invalidation::hook_ref_updated(self, &u.name).await;
173 }
174 Ok(())
175 }
176
177 pub async fn delete_refs_atomic(&self, names: Vec<String>) -> Result<(), BabyError> {
178 if names.is_empty() {
179 return Err(BabyError::Custom(
180 "delete_refs_atomic requires at least one name".to_string(),
181 ));
182 }
183 for n in &names {
184 share::validate_ref_name(n)?;
185 }
186 let stdin = helper::encode_atomic_delete(&names)?;
187 let mut cmd = self
188 .spawn_refs_cmd(|dir, env| helper::build_delete_ref_cmd(dir, env, "", true))
189 .await?;
190 if let Err(e) = cmd.feed(&stdin).await {
191 return Err(helper::classify_cmd_error(e, None));
192 }
193 let output = cmd
194 .run_soft()
195 .await
196 .map_err(|e| helper::classify_cmd_error(e, None))?;
197 if !output.status.success() {
198 return Err(BabyError::RefUpdateRejected(
199 String::from_utf8_lossy(&output.stderr).into_owned(),
200 ));
201 }
202 for n in &names {
203 let _ = crate::cache::invalidation::hook_ref_updated(self, n).await;
204 }
205 Ok(())
206 }
207
208 async fn spawn_refs_cmd<F>(&self, builder: F) -> Result<crate::command::cmd::Cmd, BabyError>
209 where
210 F: FnOnce(&std::path::Path, crate::command::env::Env) -> crate::command::cmd::Cmd,
211 {
212 let dir = self
213 .facade
214 .git_repo_dir()
215 .await
216 .map_err(share::facade_error)?;
217 let alternates = self
218 .facade
219 .git_alternate_object_directories()
220 .await
221 .map_err(share::facade_error)?;
222 let env = share::build_env_safe(&alternates);
223 Ok(builder(&dir, env))
224 }
225}
Added lib/gitbaby/src/remote/helper.rs lib/gitbaby/src/remote/helper.rs +183 −0 View lib/gitbaby/src/remote/helper.rs
@@ -0,0 +1,183 @@
1use std::path::Path;
2
3use crate::command::cmd::Cmd;
4use crate::command::env::Env;
5use crate::error::BabyError;
6use crate::remote::types::{RemoteOption, UpdateRemoteMirrorRequest};
7
8pub fn build_get_url_cmd(caller: &str, dir: &Path, env: Env, name: &str) -> Result<Cmd, BabyError> {
9 crate::share::build_remote_op_cmd(
10 caller,
11 dir,
12 env,
13 crate::share::RemoteOp::GetUrl,
14 name,
15 None,
16 crate::share::RemoteMirror::Default,
17 )
18}
19
20pub fn build_add_cmd(
21 caller: &str,
22 dir: &Path,
23 env: Env,
24 name: &str,
25 url: &str,
26 mirror: RemoteOption,
27) -> Result<Cmd, BabyError> {
28 crate::share::build_remote_op_cmd(
29 caller,
30 dir,
31 env,
32 crate::share::RemoteOp::Add,
33 name,
34 Some(url),
35 mirror.as_arg(),
36 )
37}
38
39pub fn build_remove_cmd(caller: &str, dir: &Path, env: Env, name: &str) -> Result<Cmd, BabyError> {
40 crate::share::build_remote_op_cmd(
41 caller,
42 dir,
43 env,
44 crate::share::RemoteOp::Remove,
45 name,
46 None,
47 crate::share::RemoteMirror::Default,
48 )
49}
50
51pub fn build_show_remote_cmd(
52 caller: &str,
53 dir: &Path,
54 env: Env,
55 name: &str,
56) -> Result<Cmd, BabyError> {
57 crate::share::build_remote_op_cmd(
58 caller,
59 dir,
60 env,
61 crate::share::RemoteOp::Show,
62 name,
63 None,
64 crate::share::RemoteMirror::Default,
65 )
66}
67
68pub fn build_fetch_cmd(
69 caller: &str,
70 dir: &Path,
71 env: Env,
72 remote_path: &str,
73 commit_id: &str,
74) -> Result<Cmd, BabyError> {
75 crate::share::build_fetch_cmd(
76 caller,
77 dir,
78 env,
79 remote_path,
80 &[commit_id.to_string()],
81 false,
82 false,
83 )
84}
85
86pub fn build_ls_remote_head_cmd(
87 caller: &str,
88 dir: &Path,
89 env: Env,
90 url: &str,
91) -> Result<Cmd, BabyError> {
92 crate::share::build_ls_remote_cmd(caller, dir, env, url, &["HEAD".to_string()])
93}
94
95pub fn build_push_mirror_cmd(
96 caller: &str,
97 dir: &Path,
98 env: Env,
99 req: &UpdateRemoteMirrorRequest,
100 refspecs: Vec<String>,
101) -> Result<Cmd, BabyError> {
102 crate::share::build_push_cmd(
103 caller,
104 dir,
105 env,
106 &req.url,
107 &refspecs,
108 !req.keep_divergent_refs,
109 )
110}
111
112pub fn classify_stderr_get_url(_stderr: &str) -> Option<BabyError> {
113 None
114}
115
116pub fn classify_stderr_add(stderr: &str, name: &str) -> BabyError {
117 if stderr.contains("already exists") {
118 BabyError::RemoteAlreadyExists(name.to_string())
119 } else if stderr.contains("invalid") {
120 BabyError::InvalidRemoteUrl(name.to_string())
121 } else {
122 BabyError::Custom(format!(
123 "git remote add failed for `{}`: {}",
124 name,
125 stderr.trim()
126 ))
127 }
128}
129
130pub fn classify_stderr_rm(stderr: &str, name: &str) -> BabyError {
131 if stderr.contains("No such remote") || stderr.contains("no such remote") {
132 BabyError::RemoteNotFound(name.to_string())
133 } else {
134 BabyError::Custom(format!(
135 "git remote rm failed for `{}`: {}",
136 name,
137 stderr.trim()
138 ))
139 }
140}
141
142pub fn classify_stderr_ls_remote(stderr: &str) -> BabyError {
143 if stderr.contains("not found") || stderr.contains("could not read") {
144 BabyError::InvalidRemoteUrl(stderr.trim().to_string())
145 } else {
146 BabyError::Custom(format!("git ls-remote failed: {}", stderr.trim()))
147 }
148}
149
150pub fn classify_stderr_show_remote(_stderr: &str) -> Option<BabyError> {
151 None
152}
153
154pub fn classify_stderr_push(stderr: &str) -> BabyError {
155 BabyError::Custom(format!("git push failed: {}", stderr.trim()))
156}
157
158pub fn classify_stderr_fetch(stderr: &str) -> BabyError {
159 BabyError::Custom(format!("git fetch failed: {}", stderr.trim()))
160}
161
162pub fn parse_head_branch_line(output: &[u8]) -> Option<String> {
163 let prefix = "HEAD branch: ";
164 for line in String::from_utf8_lossy(output).lines() {
165 let trimmed = line.trim();
166 if let Some(rest) = trimmed.strip_prefix(prefix) {
167 return Some(rest.to_string());
168 }
169 }
170 None
171}
172
173pub fn parse_oid_tab_head(output: &[u8]) -> Option<(gix::ObjectId, bool)> {
174 let text = String::from_utf8_lossy(output);
175 let first_line = text.lines().next()?;
176 let (oid_str, refname) = first_line.split_once('\t')?;
177 let refname = refname.trim();
178 if refname != "HEAD" {
179 return None;
180 }
181 let oid = crate::share::parse_object_id(oid_str).ok()?;
182 Some((oid, true))
183}
Added lib/gitbaby/src/remote/mod.rs lib/gitbaby/src/remote/mod.rs +7 −0 View lib/gitbaby/src/remote/mod.rs
@@ -0,0 +1,7 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{
6 FetchRemoteCommitResult, RemoteOption, UpdateRemoteMirrorRequest, UpdateRemoteMirrorResult,
7};
Added lib/gitbaby/src/remote/types.rs lib/gitbaby/src/remote/types.rs +37 −0 View lib/gitbaby/src/remote/types.rs
@@ -0,0 +1,37 @@
1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
4pub enum RemoteOption {
5 #[default]
6 Default,
7 MirrorPush,
8 MirrorFetch,
9}
10
11impl RemoteOption {
12 pub fn as_arg(self) -> crate::share::RemoteMirror {
13 match self {
14 Self::Default => crate::share::RemoteMirror::Default,
15 Self::MirrorPush => crate::share::RemoteMirror::Push,
16 Self::MirrorFetch => crate::share::RemoteMirror::Fetch,
17 }
18 }
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct UpdateRemoteMirrorRequest {
23 pub url: String,
24 pub refs: Vec<String>,
25 pub only_branches_matching: Vec<String>,
26 pub keep_divergent_refs: bool,
27}
28
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30pub struct UpdateRemoteMirrorResult {
31 pub divergent_refs: Vec<String>,
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35pub struct FetchRemoteCommitResult {
36 pub fetched: bool,
37}
Added lib/gitbaby/src/remote/usecase.rs lib/gitbaby/src/remote/usecase.rs +226 −0 View lib/gitbaby/src/remote/usecase.rs
@@ -0,0 +1,226 @@
1use crate::GitBaby;
2use crate::error::BabyError;
3use crate::remote::helper;
4use crate::remote::types::{
5 FetchRemoteCommitResult, RemoteOption, UpdateRemoteMirrorRequest, UpdateRemoteMirrorResult,
6};
7
8async fn build_env(
9 gitbaby: &GitBaby,
10) -> Result<(std::path::PathBuf, crate::command::env::Env), BabyError> {
11 let dir = gitbaby
12 .facade
13 .git_repo_dir()
14 .await
15 .map_err(crate::share::facade_error)?;
16 let alternates = gitbaby
17 .facade
18 .git_alternate_object_directories()
19 .await
20 .map_err(crate::share::facade_error)?;
21 Ok((dir, crate::share::build_env_safe(&alternates)))
22}
23
24impl GitBaby {
25 pub async fn get_remote_address(&self, name: &str) -> Result<Option<String>, BabyError> {
26 if name.is_empty() {
27 return Err(BabyError::Custom(
28 "remote name must not be empty".to_string(),
29 ));
30 }
31 let (dir, env) = build_env(self).await?;
32 let mut cmd = helper::build_get_url_cmd("gitbaby::remote", &dir, env, name)?;
33 match cmd.run().await {
34 Ok(output) => {
35 if output.status.success() {
36 let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
37 return Ok(Some(url));
38 }
39 let stderr = String::from_utf8_lossy(&output.stderr);
40 if stderr.contains("No such remote") || stderr.contains("no such remote") {
41 return Ok(None);
42 }
43 Err(BabyError::Custom(format!(
44 "git remote get-url failed: {}",
45 stderr.trim()
46 )))
47 }
48 Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => {
49 if stderr.contains("No such remote") || stderr.contains("no such remote") {
50 Ok(None)
51 } else {
52 Err(BabyError::Custom(format!(
53 "git remote get-url failed: {}",
54 stderr.trim()
55 )))
56 }
57 }
58 Err(e) => Err(BabyError::from(e)),
59 }
60 }
61
62 pub async fn add_remote(
63 &self,
64 name: &str,
65 url: &str,
66 opts: RemoteOption,
67 ) -> Result<(), BabyError> {
68 if name.is_empty() {
69 return Err(BabyError::Custom(
70 "remote name must not be empty".to_string(),
71 ));
72 }
73 if url.is_empty() {
74 return Err(BabyError::InvalidRemoteUrl(url.to_string()));
75 }
76 let (dir, env) = build_env(self).await?;
77 let mut cmd = helper::build_add_cmd("gitbaby::remote", &dir, env, name, url, opts)?;
78 let output = cmd.run_soft().await.map_err(BabyError::from)?;
79 if output.status.success() {
80 return Ok(());
81 }
82 Err(helper::classify_stderr_add(
83 &String::from_utf8_lossy(&output.stderr),
84 name,
85 ))
86 }
87
88 pub async fn remove_remote(&self, name: &str) -> Result<(), BabyError> {
89 if name.is_empty() {
90 return Err(BabyError::Custom(
91 "remote name must not be empty".to_string(),
92 ));
93 }
94 let (dir, env) = build_env(self).await?;
95 let mut cmd = helper::build_remove_cmd("gitbaby::remote", &dir, env, name)?;
96 let output = cmd.run_soft().await.map_err(BabyError::from)?;
97 if output.status.success() {
98 return Ok(());
99 }
100 Err(helper::classify_stderr_rm(
101 &String::from_utf8_lossy(&output.stderr),
102 name,
103 ))
104 }
105
106 pub async fn fetch_remote_commit(
107 &self,
108 remote_repo_path: &str,
109 commit_id: &str,
110 ) -> Result<FetchRemoteCommitResult, BabyError> {
111 if remote_repo_path.is_empty() {
112 return Err(BabyError::Custom(
113 "remote repository path must not be empty".to_string(),
114 ));
115 }
116 if commit_id.is_empty() {
117 return Err(BabyError::Custom("commit id must not be empty".to_string()));
118 }
119 let (dir, env) = build_env(self).await?;
120 let mut cmd =
121 helper::build_fetch_cmd("gitbaby::remote", &dir, env, remote_repo_path, commit_id)?;
122 let output = cmd.run_soft().await.map_err(BabyError::from)?;
123 if output.status.success() {
124 let _ = crate::cache::invalidation::flush_cache().await;
125 return Ok(FetchRemoteCommitResult { fetched: true });
126 }
127 let stderr = String::from_utf8_lossy(&output.stderr);
128 if stderr.contains("not a git repository") {
129 return Err(BabyError::NotARepository(remote_repo_path.to_string()));
130 }
131 Err(helper::classify_stderr_fetch(&stderr))
132 }
133
134 pub async fn find_remote_repository(&self, url: &str) -> Result<bool, BabyError> {
135 if url.is_empty() {
136 return Err(BabyError::InvalidRemoteUrl(url.to_string()));
137 }
138 let dir = self
139 .facade
140 .git_repo_dir()
141 .await
142 .map_err(crate::share::facade_error)?;
143 let alternates = self
144 .facade
145 .git_alternate_object_directories()
146 .await
147 .map_err(crate::share::facade_error)?;
148 let env = crate::share::build_env_safe(&alternates);
149 let mut cmd = helper::build_ls_remote_head_cmd("gitbaby::remote", &dir, env, url)?;
150 match cmd.run().await {
151 Ok(output) => {
152 if !output.status.success() {
153 return Ok(false);
154 }
155 Ok(helper::parse_oid_tab_head(&output.stdout).is_some())
156 }
157 Err(crate::command::error::CmdError::Spawn { .. }) => Ok(false),
158 Err(crate::command::error::CmdError::NonZeroExit { .. }) => Ok(false),
159 Err(crate::command::error::CmdError::Io { .. }) => Ok(false),
160 Err(e) => Err(helper::classify_stderr_ls_remote(&format!("{:?}", e))),
161 }
162 }
163
164 pub async fn find_remote_root_ref(&self, url: &str) -> Result<Option<String>, BabyError> {
165 if url.is_empty() {
166 return Err(BabyError::InvalidRemoteUrl(url.to_string()));
167 }
168 let dir = self
169 .facade
170 .git_repo_dir()
171 .await
172 .map_err(crate::share::facade_error)?;
173 let alternates = self
174 .facade
175 .git_alternate_object_directories()
176 .await
177 .map_err(crate::share::facade_error)?;
178 let env = crate::share::build_env_safe(&alternates);
179 let mut cmd = helper::build_show_remote_cmd("gitbaby::remote", &dir, env, url)?;
180 let output = match cmd.run().await {
181 Ok(o) => o,
182 Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => {
183 if helper::classify_stderr_show_remote(&stderr).is_none() {
184 return Ok(None);
185 }
186 return Err(BabyError::Custom(format!(
187 "git remote show failed: {}",
188 stderr.trim()
189 )));
190 }
191 Err(e) => return Err(BabyError::from(e)),
192 };
193 if !output.status.success() {
194 return Ok(None);
195 }
196 match helper::parse_head_branch_line(&output.stdout) {
197 Some(ref_name) if ref_name == "(unknown)" => Ok(None),
198 Some(ref_name) => Ok(Some(ref_name)),
199 None => Ok(None),
200 }
201 }
202
203 pub async fn update_remote_mirror(
204 &self,
205 req: UpdateRemoteMirrorRequest,
206 ) -> Result<UpdateRemoteMirrorResult, BabyError> {
207 if req.url.is_empty() {
208 return Err(BabyError::InvalidRemoteUrl(req.url.clone()));
209 }
210 if req.refs.is_empty() {
211 return Err(BabyError::Custom(
212 "update_remote_mirror requires at least one ref".to_string(),
213 ));
214 }
215 let (dir, env) = build_env(self).await?;
216 let mut cmd =
217 helper::build_push_mirror_cmd("gitbaby::remote", &dir, env, &req, req.refs.clone())?;
218 let output = cmd.run_soft().await.map_err(BabyError::from)?;
219 if output.status.success() {
220 return Ok(UpdateRemoteMirrorResult::default());
221 }
222 Err(helper::classify_stderr_push(&String::from_utf8_lossy(
223 &output.stderr,
224 )))
225 }
226}
Added lib/gitbaby/src/server/backend.rs lib/gitbaby/src/server/backend.rs +37 −0 View lib/gitbaby/src/server/backend.rs
@@ -0,0 +1,37 @@
1use async_trait::async_trait;
2
3use crate::server::error::ServerResult;
4use crate::server::request::{AuthContext, RefUpdateBatch, ServerRequest};
5use crate::server::stream::{BoxedAsyncRead, IngestReport};
6
7#[async_trait]
8pub trait ServerBackend: Send + Sync + 'static {
9 async fn authorize(&self, req: &ServerRequest) -> ServerResult<AuthContext>;
10
11 async fn list_refs_stream(&self, req: &ServerRequest) -> ServerResult<BoxedAsyncRead>;
12
13 async fn produce_pack_stream(
14 &self,
15 req: &ServerRequest,
16 wants: &[String],
17 ) -> ServerResult<BoxedAsyncRead>;
18
19 async fn ingest_pack_stream(
20 &self,
21 req: &ServerRequest,
22 batch: RefUpdateBatch,
23 ) -> ServerResult<IngestReport>;
24
25 async fn lfs_get_stream(&self, req: &ServerRequest, oid: &str) -> ServerResult<BoxedAsyncRead>;
26
27 async fn lfs_put_stream(
28 &self,
29 req: &ServerRequest,
30 oid: &str,
31 declared_size: u64,
32 ) -> ServerResult<IngestReport>;
33}
34
35pub fn empty_body() -> BoxedAsyncRead {
36 Box::new(tokio::io::empty())
37}
Added lib/gitbaby/src/server/endpoint.rs lib/gitbaby/src/server/endpoint.rs +51 −0 View lib/gitbaby/src/server/endpoint.rs
@@ -0,0 +1,51 @@
1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2pub enum EndpointKind {
3 GitUploadPack,
4 GitReceivePack,
5 LfsBatch,
6 LfsObjects,
7 InfoRefs,
8 SmartHttp,
9 Custom,
10}
11
12impl EndpointKind {
13 pub fn as_str(&self) -> &'static str {
14 match self {
15 Self::GitUploadPack => "git-upload-pack",
16 Self::GitReceivePack => "git-receive-pack",
17 Self::LfsBatch => "lfs-batch",
18 Self::LfsObjects => "lfs-objects",
19 Self::InfoRefs => "info-refs",
20 Self::SmartHttp => "smart-http",
21 Self::Custom => "custom",
22 }
23 }
24}
25
26#[derive(Debug, Clone)]
27pub struct GitProtocolEndpoint {
28 pub kind: EndpointKind,
29 pub path: String,
30}
31
32impl GitProtocolEndpoint {
33 pub fn new(kind: EndpointKind, path: impl Into<String>) -> Self {
34 Self {
35 kind,
36 path: path.into(),
37 }
38 }
39
40 pub fn upload_pack(path: impl Into<String>) -> Self {
41 Self::new(EndpointKind::GitUploadPack, path)
42 }
43
44 pub fn receive_pack(path: impl Into<String>) -> Self {
45 Self::new(EndpointKind::GitReceivePack, path)
46 }
47
48 pub fn info_refs(path: impl Into<String>) -> Self {
49 Self::new(EndpointKind::InfoRefs, path)
50 }
51}
Added lib/gitbaby/src/server/error.rs lib/gitbaby/src/server/error.rs +78 −0 View lib/gitbaby/src/server/error.rs
@@ -0,0 +1,78 @@
1use std::fmt;
2use std::path::PathBuf;
3
4use crate::error::BabyError;
5
6#[derive(Debug)]
7pub enum ServerError {
8 Unimplemented(&'static str),
9 Unauthorised {
10 scheme: &'static str,
11 reason: String,
12 },
13 InvalidRequest(String),
14 PayloadTooLarge {
15 cap_bytes: u64,
16 actual_bytes: u64,
17 },
18 Io {
19 path: PathBuf,
20 source: std::io::Error,
21 },
22 Pipe(String),
23 Backend(BabyError),
24 Cancelled,
25}
26
27impl fmt::Display for ServerError {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 match self {
30 Self::Unimplemented(what) => write!(f, "not yet implemented: {}", what),
31 Self::Unauthorised { scheme, reason } => {
32 write!(f, "unauthorised ({scheme}): {}", reason)
33 }
34 Self::InvalidRequest(msg) => write!(f, "invalid server request: {}", msg),
35 Self::PayloadTooLarge {
36 cap_bytes,
37 actual_bytes,
38 } => write!(
39 f,
40 "output exceeds cap: {} bytes > {} bytes cap",
41 actual_bytes, cap_bytes
42 ),
43 Self::Io { path, source } => {
44 write!(f, "io error at `{}`: {}", path.display(), source)
45 }
46 Self::Pipe(msg) => write!(f, "server pipe error: {}", msg),
47 Self::Backend(err) => write!(f, "backend error: {}", err),
48 Self::Cancelled => write!(f, "server stream was cancelled"),
49 }
50 }
51}
52
53impl std::error::Error for ServerError {
54 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
55 match self {
56 Self::Io { source, .. } => Some(source),
57 Self::Backend(err) => Some(err),
58 _ => None,
59 }
60 }
61}
62
63impl From<BabyError> for ServerError {
64 fn from(err: BabyError) -> Self {
65 Self::Backend(err)
66 }
67}
68
69impl From<std::io::Error> for ServerError {
70 fn from(source: std::io::Error) -> Self {
71 Self::Io {
72 path: PathBuf::new(),
73 source,
74 }
75 }
76}
77
78pub type ServerResult<T> = std::result::Result<T, ServerError>;
Added lib/gitbaby/src/server/limits.rs lib/gitbaby/src/server/limits.rs +3 −0 View lib/gitbaby/src/server/limits.rs
@@ -0,0 +1,3 @@
1pub use crate::command::cmd::DEFAULT_OUTPUT_CAP_BYTES;
2
3pub const DEFAULT_LFS_OBJECT_CAP_BYTES: u64 = 5_u64 * 1024 * 1024 * 1024;
Added lib/gitbaby/src/server/mod.rs lib/gitbaby/src/server/mod.rs +19 −0 View lib/gitbaby/src/server/mod.rs
@@ -0,0 +1,19 @@
1pub mod backend;
2pub mod endpoint;
3pub mod error;
4pub mod limits;
5pub mod request;
6pub mod stream;
7
8pub use backend::ServerBackend;
9pub use endpoint::{EndpointKind, GitProtocolEndpoint};
10pub use error::{ServerError, ServerResult};
11pub use limits::{DEFAULT_LFS_OBJECT_CAP_BYTES, DEFAULT_OUTPUT_CAP_BYTES};
12pub use request::{
13 AuthContext, AuthScheme, HeaderMap, Principal, RefUpdateBatch, RefUpdateEntry, ServerRequest,
14 StreamGuard, StreamGuardHandle,
15};
16pub use stream::{
17 BoxedAsyncBufRead, BoxedAsyncRead, BoxedAsyncWrite, ChildPackReader, ChildPackWriter,
18 IngestReport, PackStats, StreamOutcome, buf_empty_reader, empty_reader, sink_writer,
19};
Added lib/gitbaby/src/server/request.rs lib/gitbaby/src/server/request.rs +284 −0 View lib/gitbaby/src/server/request.rs
@@ -0,0 +1,284 @@
1use std::collections::HashMap;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use tokio::io::AsyncRead;
6
7use crate::server::endpoint::EndpointKind;
8use crate::server::limits::DEFAULT_OUTPUT_CAP_BYTES;
9
10/// Case-insensitive HTTP header map. Header names are stored lower-cased so
11/// lookups match regardless of case (per HTTP semantics), without pulling in
12/// the `http` crate.
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct HeaderMap {
15 inner: HashMap<String, String>,
16}
17
18impl HeaderMap {
19 pub fn new() -> Self {
20 Self {
21 inner: HashMap::new(),
22 }
23 }
24
25 /// Insert a header, normalizing the name to lower-case.
26 pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) -> Option<String> {
27 self.inner
28 .insert(key.into().to_ascii_lowercase(), value.into())
29 }
30
31 /// Case-insensitive lookup.
32 pub fn get(&self, key: &str) -> Option<&String> {
33 self.inner.get(&key.to_ascii_lowercase())
34 }
35
36 pub fn remove(&mut self, key: &str) -> Option<String> {
37 self.inner.remove(&key.to_ascii_lowercase())
38 }
39
40 pub fn contains_key(&self, key: &str) -> bool {
41 self.inner.contains_key(&key.to_ascii_lowercase())
42 }
43
44 pub fn len(&self) -> usize {
45 self.inner.len()
46 }
47
48 pub fn is_empty(&self) -> bool {
49 self.inner.is_empty()
50 }
51
52 pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
53 self.inner.iter()
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum AuthScheme {
59 #[default]
60 Anonymous,
61 Basic,
62 Bearer,
63 Ssh,
64}
65
66impl AuthScheme {
67 pub fn as_str(&self) -> &'static str {
68 match self {
69 Self::Anonymous => "anonymous",
70 Self::Basic => "basic",
71 Self::Bearer => "bearer",
72 Self::Ssh => "ssh",
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
78pub struct Principal {
79 pub name: String,
80 pub scheme: AuthScheme,
81}
82
83impl Principal {
84 pub fn new(name: impl Into<String>, scheme: AuthScheme) -> Self {
85 Self {
86 name: name.into(),
87 scheme,
88 }
89 }
90}
91
92#[derive(Debug, Clone, Default)]
93pub struct AuthContext {
94 pub scheme: AuthScheme,
95 pub principal: Option<Principal>,
96 pub raw: Option<String>,
97}
98
99impl AuthContext {
100 pub fn anonymous() -> Self {
101 Self {
102 scheme: AuthScheme::Anonymous,
103 principal: None,
104 raw: None,
105 }
106 }
107
108 pub fn with_principal(scheme: AuthScheme, principal: Principal) -> Self {
109 Self {
110 scheme,
111 principal: Some(principal),
112 raw: None,
113 }
114 }
115}
116
117#[derive(Debug, Clone)]
118pub struct RefUpdateEntry {
119 pub name: String,
120 pub new_oid: String,
121 pub old_oid: Option<String>,
122}
123
124#[derive(Debug, Clone, Default)]
125pub struct RefUpdateBatch {
126 pub updates: Vec<RefUpdateEntry>,
127}
128
129impl RefUpdateBatch {
130 pub fn new() -> Self {
131 Self::default()
132 }
133
134 pub fn push(
135 mut self,
136 name: impl Into<String>,
137 new_oid: impl Into<String>,
138 old_oid: Option<String>,
139 ) -> Self {
140 self.updates.push(RefUpdateEntry {
141 name: name.into(),
142 new_oid: new_oid.into(),
143 old_oid,
144 });
145 self
146 }
147
148 pub fn is_empty(&self) -> bool {
149 self.updates.is_empty()
150 }
151
152 pub fn len(&self) -> usize {
153 self.updates.len()
154 }
155}
156
157pub struct ServerRequest {
158 pub method: String,
159 pub path: String,
160 pub query: Option<String>,
161 pub headers: HeaderMap,
162 pub body: Box<dyn AsyncRead + Unpin + Send + Sync>,
163 pub guard: StreamGuard,
164 pub output_cap: u64,
165 pub auth: Option<AuthContext>,
166 pub endpoint: EndpointKind,
167 pub remote_addr: Option<String>,
168 pub service: String,
169}
170
171impl std::fmt::Debug for ServerRequest {
172 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173 f.debug_struct("ServerRequest")
174 .field("method", &self.method)
175 .field("path", &self.path)
176 .field("query", &self.query)
177 .field("headers", &self.headers)
178 .field("body", &"<dyn AsyncRead>")
179 .field("guard", &self.guard)
180 .field("output_cap", &self.output_cap)
181 .field("auth", &self.auth)
182 .field("endpoint", &self.endpoint)
183 .field("remote_addr", &self.remote_addr)
184 .field("service", &self.service)
185 .finish()
186 }
187}
188
189impl ServerRequest {
190 pub fn new(
191 method: impl Into<String>,
192 path: impl Into<String>,
193 endpoint: EndpointKind,
194 service: impl Into<String>,
195 body: Box<dyn AsyncRead + Unpin + Send + Sync>,
196 ) -> Self {
197 Self {
198 method: method.into(),
199 path: path.into(),
200 query: None,
201 headers: HeaderMap::new(),
202 body,
203 guard: StreamGuard::new(),
204 output_cap: DEFAULT_OUTPUT_CAP_BYTES as u64,
205 auth: None,
206 endpoint,
207 remote_addr: None,
208 service: service.into(),
209 }
210 }
211
212 pub fn with_query(mut self, query: impl Into<String>) -> Self {
213 self.query = Some(query.into());
214 self
215 }
216
217 pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
218 self.headers.insert(key.into(), value.into());
219 self
220 }
221
222 pub fn with_auth(mut self, auth: AuthContext) -> Self {
223 self.auth = Some(auth);
224 self
225 }
226
227 pub fn with_output_cap(mut self, cap_bytes: u64) -> Self {
228 self.output_cap = cap_bytes;
229 self
230 }
231
232 pub fn with_remote_addr(mut self, addr: impl Into<String>) -> Self {
233 self.remote_addr = Some(addr.into());
234 self
235 }
236}
237
238#[derive(Debug, Clone)]
239pub struct StreamGuard {
240 cancelled: Arc<AtomicBool>,
241}
242
243impl Default for StreamGuard {
244 fn default() -> Self {
245 Self::new()
246 }
247}
248
249impl StreamGuard {
250 pub fn new() -> Self {
251 Self {
252 cancelled: Arc::new(AtomicBool::new(false)),
253 }
254 }
255
256 pub fn cancel(&self) {
257 self.cancelled.store(true, Ordering::SeqCst);
258 }
259
260 pub fn is_cancelled(&self) -> bool {
261 self.cancelled.load(Ordering::SeqCst)
262 }
263
264 pub fn handle(&self) -> StreamGuardHandle {
265 StreamGuardHandle {
266 cancelled: self.cancelled.clone(),
267 }
268 }
269}
270
271#[derive(Debug, Clone)]
272pub struct StreamGuardHandle {
273 cancelled: Arc<AtomicBool>,
274}
275
276impl StreamGuardHandle {
277 pub fn is_cancelled(&self) -> bool {
278 self.cancelled.load(Ordering::SeqCst)
279 }
280
281 pub fn cancel(&self) {
282 self.cancelled.store(true, Ordering::SeqCst);
283 }
284}
Added lib/gitbaby/src/server/stream.rs lib/gitbaby/src/server/stream.rs +270 −0 View lib/gitbaby/src/server/stream.rs
@@ -0,0 +1,270 @@
1use std::path::PathBuf;
2
3use tokio::io::{AsyncBufRead, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader};
4use tokio::process::Child;
5
6use crate::server::error::{ServerError, ServerResult};
7use crate::server::limits::DEFAULT_OUTPUT_CAP_BYTES;
8
9pub struct ChildPackReader<R: AsyncBufRead + Unpin + Send> {
10 reader: R,
11 child: Option<Child>,
12 finished: bool,
13}
14
15impl<R: AsyncBufRead + Unpin + Send> ChildPackReader<R> {
16 pub fn new(reader: R, child: Option<Child>) -> Self {
17 Self {
18 reader,
19 child,
20 finished: false,
21 }
22 }
23
24 pub fn finished(&self) -> bool {
25 self.finished
26 }
27
28 pub fn child_handle(&mut self) -> Option<&mut Child> {
29 self.child.as_mut()
30 }
31
32 pub async fn next_chunk(&mut self, buf: &mut [u8]) -> ServerResult<usize> {
33 if self.finished {
34 return Ok(0);
35 }
36 let n = self
37 .reader
38 .read(buf)
39 .await
40 .map_err(|source| ServerError::Io {
41 path: PathBuf::new(),
42 source,
43 })?;
44 if n == 0 {
45 self.finished = true;
46 }
47 Ok(n)
48 }
49
50 pub async fn read_to_end_capped(&mut self, cap: Option<u64>) -> ServerResult<StreamOutcome> {
51 let cap = cap.unwrap_or(DEFAULT_OUTPUT_CAP_BYTES as u64);
52 let mut buf = vec![0u8; 32 * 1024];
53 let mut total: u64 = 0;
54 loop {
55 let n = self.next_chunk(&mut buf).await?;
56 if n == 0 {
57 return Ok(StreamOutcome::Eof);
58 }
59 total = total.saturating_add(n as u64);
60 if total > cap {
61 return Err(ServerError::PayloadTooLarge {
62 cap_bytes: cap,
63 actual_bytes: total,
64 });
65 }
66 }
67 }
68
69 pub async fn collect_pack_stats(&mut self, cap: Option<u64>) -> ServerResult<PackStats> {
70 let mut stats = PackStats::default();
71 let mut buf = vec![0u8; 32 * 1024];
72 loop {
73 let n = self.next_chunk(&mut buf).await?;
74 if n == 0 {
75 break;
76 }
77 stats.bytes_streamed = stats.bytes_streamed.saturating_add(n as u64);
78 if let Some(limit) = cap
79 && stats.bytes_streamed > limit
80 {
81 return Err(ServerError::PayloadTooLarge {
82 cap_bytes: limit,
83 actual_bytes: stats.bytes_streamed,
84 });
85 }
86 }
87 Ok(stats)
88 }
89}
90
91impl<R: AsyncBufRead + Unpin + Send> Drop for ChildPackReader<R> {
92 fn drop(&mut self) {
93 if let Some(mut child) = self.child.take() {
94 // If the stream was cancelled mid-flight (not cleanly finished),
95 // kill the child so it cannot linger as a runaway process.
96 if !self.finished {
97 let _ = child.start_kill();
98 }
99 reap_child(child);
100 }
101 }
102}
103
104pub struct ChildPackWriter<W: AsyncWrite + Unpin + Send> {
105 writer: W,
106 child: Option<Child>,
107 finished: bool,
108}
109
110impl<W: AsyncWrite + Unpin + Send> ChildPackWriter<W> {
111 pub fn new(writer: W, child: Option<Child>) -> Self {
112 Self {
113 writer,
114 child,
115 finished: false,
116 }
117 }
118
119 pub fn finished(&self) -> bool {
120 self.finished
121 }
122
123 pub fn child_handle(&mut self) -> Option<&mut Child> {
124 self.child.as_mut()
125 }
126
127 pub async fn write_chunk(&mut self, buf: &[u8]) -> ServerResult<usize> {
128 if self.finished {
129 return Err(ServerError::Pipe(
130 "cannot write to a finished child writer".to_string(),
131 ));
132 }
133 self.writer
134 .write_all(buf)
135 .await
136 .map_err(|source| ServerError::Io {
137 path: PathBuf::new(),
138 source,
139 })?;
140 Ok(buf.len())
141 }
142
143 pub async fn copy_from<R>(&mut self, mut reader: R) -> ServerResult<StreamOutcome>
144 where
145 R: AsyncRead + Unpin + Send,
146 {
147 let mut buf = vec![0u8; 32 * 1024];
148 let mut total: u64 = 0;
149 loop {
150 let n = reader
151 .read(&mut buf)
152 .await
153 .map_err(|source| ServerError::Io {
154 path: PathBuf::new(),
155 source,
156 })?;
157 if n == 0 {
158 break;
159 }
160 self.write_chunk(&buf[..n]).await?;
161 total = total.saturating_add(n as u64);
162 }
163 self.writer
164 .flush()
165 .await
166 .map_err(|source| ServerError::Io {
167 path: PathBuf::new(),
168 source,
169 })?;
170 Ok(StreamOutcome::Bytes(total))
171 }
172
173 pub async fn finish(mut self) -> ServerResult<()> {
174 self.writer
175 .shutdown()
176 .await
177 .map_err(|source| ServerError::Io {
178 path: PathBuf::new(),
179 source,
180 })?;
181 self.finished = true;
182 Ok(())
183 }
184}
185
186impl<W: AsyncWrite + Unpin + Send> Drop for ChildPackWriter<W> {
187 fn drop(&mut self) {
188 if let Some(mut child) = self.child.take() {
189 if !self.finished {
190 let _ = child.start_kill();
191 }
192 reap_child(child);
193 }
194 }
195}
196
197#[derive(Debug, Clone, Default, PartialEq, Eq)]
198pub struct PackStats {
199 pub bytes_streamed: u64,
200 pub objects_reported: u64,
201}
202
203impl PackStats {
204 pub fn is_empty(&self) -> bool {
205 self.bytes_streamed == 0 && self.objects_reported == 0
206 }
207}
208
209#[derive(Debug, Clone, Default)]
210pub struct IngestReport {
211 pub stats: PackStats,
212 pub refs_updated: Vec<String>,
213}
214
215impl IngestReport {
216 pub fn new(stats: PackStats, refs_updated: Vec<String>) -> Self {
217 Self {
218 stats,
219 refs_updated,
220 }
221 }
222
223 pub fn empty() -> Self {
224 Self::default()
225 }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub enum StreamOutcome {
230 Eof,
231 Bytes(u64),
232}
233
234impl StreamOutcome {
235 pub fn bytes(&self) -> Option<u64> {
236 match self {
237 Self::Eof => None,
238 Self::Bytes(n) => Some(*n),
239 }
240 }
241}
242
243pub type BoxedAsyncRead = Box<dyn AsyncRead + Unpin + Send + Sync>;
244
245pub type BoxedAsyncWrite = Box<dyn AsyncWrite + Unpin + Send + Sync>;
246
247pub type BoxedAsyncBufRead = Box<dyn AsyncBufRead + Unpin + Send + Sync>;
248
249pub fn empty_reader() -> BoxedAsyncRead {
250 Box::new(tokio::io::empty())
251}
252
253pub fn sink_writer() -> BoxedAsyncWrite {
254 Box::new(tokio::io::sink())
255}
256
257pub fn buf_empty_reader() -> BoxedAsyncBufRead {
258 Box::new(BufReader::new(tokio::io::empty()))
259}
260
261/// Reap a child process without blocking the current (potentially sync) drop
262/// path. Spawns a reaper task if a tokio runtime is available; otherwise the
263/// child is abandoned to the OS (best-effort — cannot `wait` without a runtime).
264fn reap_child(mut child: Child) {
265 if let Ok(handle) = tokio::runtime::Handle::try_current() {
266 handle.spawn(async move {
267 let _ = child.wait().await;
268 });
269 }
270}
Added lib/gitbaby/src/setup/helper.rs lib/gitbaby/src/setup/helper.rs +139 −0 View lib/gitbaby/src/setup/helper.rs
@@ -0,0 +1,139 @@
1use std::path::Path;
2
3use crate::command::cmd::Cmd;
4use crate::command::env::Env;
5use crate::error::BabyError;
6use crate::setup::types::FastImportCommit;
7
8pub fn build_init_cmd(
9 caller: &str,
10 dir: &Path,
11 env: Env,
12 path: &str,
13 bare: bool,
14 object_format: Option<&str>,
15) -> Result<Cmd, BabyError> {
16 crate::share::build_init_cmd(caller, dir, env, path, bare, object_format)
17}
18
19pub fn build_fast_import_cmd(caller: &str, dir: &Path, env: Env) -> Cmd {
20 crate::share::build_fast_import_cmd(caller, dir, env)
21}
22
23pub fn build_is_repository_exists_cmd(caller: &str, path: &Path, env: Env) -> Cmd {
24 let args = vec![
25 "-C".to_string(),
26 path.to_string_lossy().into_owned(),
27 "rev-parse".to_string(),
28 "--is-inside-work-tree".to_string(),
29 ];
30 crate::share::git_cmd(caller, path, env, None, args)
31}
32
33pub fn render_fast_import_stream(commits: &[FastImportCommit]) -> Result<Vec<u8>, BabyError> {
34 let mut buf = Vec::new();
35 for (i, c) in commits.iter().enumerate() {
36 validate_fast_import_field(&c.ref_name, "ref_name")?;
37 crate::share::path::validate_ref_name(&c.ref_name)?;
38 validate_fast_import_field(&c.committer_name, "committer_name")?;
39 if c.committer_name.contains('<') || c.committer_name.contains('>') {
40 return Err(BabyError::InvalidGitArg(format!(
41 "committer_name contains `<` or `>`: `{}`",
42 c.committer_name
43 )));
44 }
45 validate_fast_import_field(&c.committer_email, "committer_email")?;
46 if c.committer_email.contains('<') || c.committer_email.contains('>') {
47 return Err(BabyError::InvalidGitArg(format!(
48 "committer_email contains `<` or `>`: `{}`",
49 c.committer_email
50 )));
51 }
52 let msg = c
53 .message
54 .clone()
55 .unwrap_or_else(|| format!("commit {}", i + 1));
56 if msg.contains('\0') {
57 return Err(BabyError::InvalidGitArg("message contains NUL".to_string()));
58 }
59 let _ = std::fmt::Write::write_fmt(
60 &mut FastImportWriter(&mut buf),
61 format_args!("reset {}\n", c.ref_name),
62 );
63 let _ = std::fmt::Write::write_fmt(
64 &mut FastImportWriter(&mut buf),
65 format_args!(
66 "commit {}\nmark :{}\ncommitter {} <{}> {} {}\n",
67 c.ref_name,
68 i + 1,
69 c.committer_name,
70 c.committer_email,
71 c.committer_time,
72 format_tz(c.committer_tz_offset),
73 ),
74 );
75 let _ = std::fmt::Write::write_fmt(
76 &mut FastImportWriter(&mut buf),
77 format_args!("data {}\n{}\n", msg.len(), msg),
78 );
79 for f in &c.files {
80 validate_fast_import_field(&f.path, "file.path")?;
81 crate::share::path::validate_local_no_escape(std::path::Path::new(&f.path))?;
82 let mode = f.mode.unwrap_or(0o100644);
83 let _ = std::fmt::Write::write_fmt(
84 &mut FastImportWriter(&mut buf),
85 format_args!("M {} inline {}\n", mode, f.path),
86 );
87 let _ = std::fmt::Write::write_fmt(
88 &mut FastImportWriter(&mut buf),
89 format_args!("data {}\n", f.content.len()),
90 );
91 buf.extend_from_slice(&f.content);
92 buf.push(b'\n');
93 }
94 }
95 buf.extend_from_slice(b"done\n");
96 Ok(buf)
97}
98
99fn validate_fast_import_field(value: &str, kind: &str) -> Result<(), BabyError> {
100 if value.is_empty() {
101 return Err(BabyError::InvalidGitArg(format!(
102 "fast-import {kind} is empty"
103 )));
104 }
105 if value.starts_with('-') {
106 return Err(BabyError::InvalidGitArg(format!(
107 "fast-import {kind} starts with `-`: `{}`",
108 value
109 )));
110 }
111 if value.contains('\n') || value.contains('\r') {
112 return Err(BabyError::InvalidGitArg(format!(
113 "fast-import {kind} contains newline: `{}`",
114 value
115 )));
116 }
117 Ok(())
118}
119
120struct FastImportWriter<'a>(&'a mut Vec<u8>);
121
122impl std::fmt::Write for FastImportWriter<'_> {
123 fn write_str(&mut self, s: &str) -> std::fmt::Result {
124 self.0.extend_from_slice(s.as_bytes());
125 Ok(())
126 }
127}
128
129pub fn format_tz(offset_seconds: i32) -> String {
130 let sign = if offset_seconds < 0 { '-' } else { '+' };
131 let abs = offset_seconds.unsigned_abs();
132 let hh = abs / 3600;
133 let mm = (abs % 3600) / 60;
134 format!("{}{:02}{:02}", sign, hh, mm)
135}
136
137pub fn classify_stderr_init(stderr: &str) -> String {
138 stderr.trim().to_string()
139}
Added lib/gitbaby/src/setup/mod.rs lib/gitbaby/src/setup/mod.rs +5 −0 View lib/gitbaby/src/setup/mod.rs
@@ -0,0 +1,5 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{FastImportCommit, FastImportFile, ObjectFormat};
Added lib/gitbaby/src/setup/types.rs lib/gitbaby/src/setup/types.rs +34 −0 View lib/gitbaby/src/setup/types.rs
@@ -0,0 +1,34 @@
1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4pub enum ObjectFormat {
5 Sha1,
6 Sha256,
7}
8
9impl ObjectFormat {
10 pub fn as_str(self) -> &'static str {
11 match self {
12 Self::Sha1 => "sha1",
13 Self::Sha256 => "sha256",
14 }
15 }
16}
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19pub struct FastImportFile {
20 pub mode: Option<u32>,
21 pub path: String,
22 pub content: Vec<u8>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct FastImportCommit {
27 pub ref_name: String,
28 pub message: Option<String>,
29 pub committer_name: String,
30 pub committer_email: String,
31 pub committer_time: i64,
32 pub committer_tz_offset: i32,
33 pub files: Vec<FastImportFile>,
34}
Added lib/gitbaby/src/setup/usecase.rs lib/gitbaby/src/setup/usecase.rs +84 −0 View lib/gitbaby/src/setup/usecase.rs
@@ -0,0 +1,84 @@
1use std::path::Path;
2
3use crate::GitBaby;
4use crate::command::env::Env;
5use crate::error::BabyError;
6use crate::setup::helper;
7use crate::setup::types::{FastImportCommit, ObjectFormat};
8
9fn empty_env() -> Env {
10 Env::new()
11}
12
13impl GitBaby {
14 pub async fn init_repository(
15 &self,
16 path: &str,
17 bare: bool,
18 object_format: Option<ObjectFormat>,
19 ) -> Result<(), BabyError> {
20 if path.is_empty() {
21 return Err(BabyError::Custom(
22 "repository path must not be empty".to_string(),
23 ));
24 }
25 if path.starts_with('-') {
26 return Err(BabyError::InvalidGitArg(format!(
27 "repository path starts with `-`: `{}`",
28 path
29 )));
30 }
31 let env = empty_env();
32 let of = object_format.map(|f| f.as_str());
33 let mut cmd =
34 helper::build_init_cmd("gitbaby::setup", Path::new(path), env, path, bare, of)?;
35 let output = cmd.run_soft().await.map_err(BabyError::from)?;
36 if output.status.success() {
37 return Ok(());
38 }
39 Err(BabyError::Custom(helper::classify_stderr_init(
40 &String::from_utf8_lossy(&output.stderr),
41 )))
42 }
43
44 pub async fn is_repository_exists(&self, path: &str) -> bool {
45 if path.is_empty() {
46 return false;
47 }
48 if path.starts_with('-') {
49 return false;
50 }
51 let env = empty_env();
52 let p = Path::new(path);
53 let mut cmd = helper::build_is_repository_exists_cmd("gitbaby::setup", p, env);
54 match cmd.run().await {
55 Ok(output) => output.status.success(),
56 Err(_) => false,
57 }
58 }
59
60 pub async fn fast_import(
61 &self,
62 dir: &Path,
63 commits: Vec<FastImportCommit>,
64 ) -> Result<(), BabyError> {
65 if commits.is_empty() {
66 return Err(BabyError::FastImportFailed(
67 "no commits provided".to_string(),
68 ));
69 }
70 let env = empty_env();
71 let stream = helper::render_fast_import_stream(&commits)?;
72 let mut cmd = helper::build_fast_import_cmd("gitbaby::setup", dir, env);
73 cmd.spawn().await.map_err(BabyError::from)?;
74 cmd.feed(&stream).await.map_err(BabyError::from)?;
75 let output = cmd.run_soft().await.map_err(BabyError::from)?;
76 if output.status.success() {
77 let _ = crate::cache::invalidation::flush_cache().await;
78 return Ok(());
79 }
80 Err(BabyError::FastImportFailed(
81 String::from_utf8_lossy(&output.stderr).trim().to_string(),
82 ))
83 }
84}
Added lib/gitbaby/src/share/cmd.rs lib/gitbaby/src/share/cmd.rs +518 −0 View lib/gitbaby/src/share/cmd.rs
@@ -0,0 +1,518 @@
1use std::io;
2use std::path::Path;
3
4use crate::command::cmd::Cmd;
5use crate::command::env::Env;
6use crate::error::BabyError;
7
8pub fn git_cmd(caller: &str, dir: &Path, env: Env, timeout: Option<u64>, args: Vec<String>) -> Cmd {
9 Cmd::new(
10 caller.to_string(),
11 "git",
12 args,
13 Vec::new(),
14 dir.to_path_buf(),
15 env,
16 timeout,
17 )
18}
19
20pub fn reject_starts_with_dash(value: &str, kind: &str) -> Result<(), BabyError> {
21 if value.starts_with('-') {
22 Err(BabyError::InvalidGitArg(format!(
23 "{} must not start with '-': `{}`",
24 kind, value
25 )))
26 } else {
27 Ok(())
28 }
29}
30
31pub fn validate_remote_url(url: &str) -> Result<(), BabyError> {
32 reject_starts_with_dash(url, "remote url")?;
33 let lower = url.to_ascii_lowercase();
34 if lower.starts_with("ext::")
35 || lower.starts_with("ssh::")
36 || lower.starts_with("git::")
37 || lower.starts_with("remote-")
38 {
39 return Err(BabyError::InvalidRemoteUrl(url.to_string()));
40 }
41 let has_scheme = lower.starts_with("http://")
42 || lower.starts_with("https://")
43 || lower.starts_with("ssh://")
44 || lower.starts_with("git://")
45 || lower.starts_with("file://");
46 if !has_scheme {
47 // Allow scp-style: [user@]host:path or relative/absolute local paths
48 if url.contains("://") {
49 return Err(BabyError::InvalidRemoteUrl(url.to_string()));
50 }
51 if url.starts_with('-') {
52 return Err(BabyError::InvalidRemoteUrl(url.to_string()));
53 }
54 if url.contains('\0') || url.contains('\n') || url.contains('\r') {
55 return Err(BabyError::InvalidRemoteUrl(url.to_string()));
56 }
57 }
58 Ok(())
59}
60
61pub fn validate_revision(rev: &str) -> Result<(), BabyError> {
62 crate::share::path::validate_revision_non_empty(rev)?;
63 reject_starts_with_dash(rev, "revision")?;
64 if rev.contains('\0') || rev.contains('\n') || rev.contains('\r') {
65 return Err(BabyError::RevisionInvalid(rev.to_string()));
66 }
67 Ok(())
68}
69
70pub fn validate_config_key(key: &str) -> Result<(), BabyError> {
71 reject_starts_with_dash(key, "config key")?;
72 if key.is_empty() {
73 return Err(BabyError::InvalidGitArg("empty config key".to_string()));
74 }
75 let mut chars = key.chars();
76 let first = chars.next().unwrap();
77 if !(first.is_ascii_alphabetic() || first == '_') {
78 return Err(BabyError::InvalidGitArg(format!(
79 "config key must start with letter or '_': `{}`",
80 key
81 )));
82 }
83 for ch in chars {
84 if !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '.' || ch == '_') {
85 return Err(BabyError::InvalidGitArg(format!(
86 "config key contains invalid character `{}` in `{}`",
87 ch, key
88 )));
89 }
90 }
91 if is_dangerous_config_key(key) {
92 return Err(BabyError::InvalidGitArg(format!(
93 "refusing to set dangerous config key `{}` via this API; use managed_config_* or explicit global opt-in",
94 key
95 )));
96 }
97 Ok(())
98}
99
100const DANGEROUS_CONFIG_KEY_PATTERNS: &[&str] = &[
101 "core.sshcommand",
102 "core.sshcommand.",
103 "core.hookspath",
104 "core.hookspath.",
105 "core.gitproxy",
106 "core.gitproxy.",
107 "credential.helper",
108 "credential.helper.",
109 "credential.username",
110 "credential.password",
111 "credential.token",
112 "http.extraheader",
113 "http.extraheader.",
114 "url.",
115 "include.path",
116 "includeif.",
117 "init.templatedir",
118 "protocol.file.allow",
119 "protocol.ext.allow",
120 "diff.",
121 "filter.",
122 "gc.auto",
123 "gc.autodetach",
124 "gpg.format",
125 "gpg.program",
126 "ssh.variant",
127 "alias.",
128 "remote.",
129 "branch.",
130 "tag.",
131];
132
133pub fn is_dangerous_config_key(key: &str) -> bool {
134 let lower = key.to_ascii_lowercase();
135 DANGEROUS_CONFIG_KEY_PATTERNS
136 .iter()
137 .any(|pat| lower == *pat || lower.starts_with(pat))
138}
139
140pub fn classify_spawn_error(prog: String, source: io::Error) -> BabyError {
141 BabyError::Spawn {
142 prog,
143 source,
144 stderr: String::new(),
145 }
146}
147
148pub fn for_each_ref_format(peel: bool) -> String {
149 if peel {
150 "%(objectname) %(objecttype) %(refname) %(*objectname)".to_string()
151 } else {
152 "%(objectname) %(objecttype) %(refname)".to_string()
153 }
154}
155
156pub fn build_for_each_ref_args(
157 patterns: &[String],
158 sort: Option<&str>,
159 limit: Option<u32>,
160 start_after: Option<&str>,
161 points_at: Option<&str>,
162 exclude: &[String],
163 peel: bool,
164) -> Vec<String> {
165 let mut args = vec![
166 "for-each-ref".to_string(),
167 format!("--format={}", for_each_ref_format(peel)),
168 ];
169 if let Some(s) = sort {
170 args.push("--sort".to_string());
171 args.push(s.to_string());
172 }
173 if let Some(l) = limit {
174 args.push("--count".to_string());
175 args.push(l.to_string());
176 }
177 if let Some(sa) = start_after {
178 args.push("--start-after".to_string());
179 args.push(sa.to_string());
180 }
181 if let Some(oid) = points_at {
182 args.push("--points-at".to_string());
183 args.push(oid.to_string());
184 }
185 for p in patterns {
186 args.push(p.clone());
187 }
188 for p in exclude {
189 args.push("--exclude".to_string());
190 args.push(p.clone());
191 }
192 args
193}
194
195pub enum CatFileSubcommand {
196 BatchCheck,
197 Batch,
198 Blob,
199}
200
201pub fn git_cat_file_cmd(
202 dir: &Path,
203 env: Env,
204 oid: &gix::ObjectId,
205 subcommand: CatFileSubcommand,
206) -> Cmd {
207 let mut args = vec!["cat-file".to_string()];
208 match subcommand {
209 CatFileSubcommand::BatchCheck => {
210 args.push("--batch-check".to_string());
211 }
212 CatFileSubcommand::Batch => {
213 args.push("--batch".to_string());
214 }
215 CatFileSubcommand::Blob => {
216 args.push("blob".to_string());
217 args.push("--end-of-options".to_string());
218 args.push(oid.to_string());
219 }
220 }
221 git_cmd("gitbaby::blob", dir, env, None, args)
222}
223
224pub fn git_hash_object_cmd(dir: &Path, env: Env, path: Option<&str>) -> Cmd {
225 let mut args = vec![
226 "hash-object".to_string(),
227 "--stdin".to_string(),
228 "-w".to_string(),
229 ];
230 if let Some(p) = path {
231 args.push("--path".to_string());
232 args.push(p.to_string());
233 }
234 git_cmd("gitbaby::blob", dir, env, None, args)
235}
236
237pub enum ConfigScope {
238 Global,
239 Local,
240}
241
242pub enum ConfigOp {
243 Get,
244 Set,
245 Add,
246 UnsetAll,
247 GetAll,
248 GetRegexp,
249}
250
251pub fn build_config_cmd(
252 caller: &str,
253 dir: &Path,
254 env: Env,
255 scope: ConfigScope,
256 op: ConfigOp,
257 key: &str,
258 value: Option<&str>,
259) -> Result<Cmd, BabyError> {
260 validate_config_key(key)?;
261 if let Some(v) = value {
262 reject_starts_with_dash(v, "config value")?;
263 if v.contains('\0') {
264 return Err(BabyError::InvalidGitArg(format!(
265 "config value contains NUL: `{}`",
266 v
267 )));
268 }
269 }
270 let mut args = vec!["config".to_string()];
271 if matches!(scope, ConfigScope::Global) {
272 args.push("--global".to_string());
273 }
274 match op {
275 ConfigOp::Get => args.push("--get".to_string()),
276 ConfigOp::Set => {}
277 ConfigOp::Add => args.push("--add".to_string()),
278 ConfigOp::UnsetAll => args.push("--unset-all".to_string()),
279 ConfigOp::GetAll => args.push("--get-all".to_string()),
280 ConfigOp::GetRegexp => args.push("--get-regexp".to_string()),
281 }
282 args.push(key.to_string());
283 if let Some(v) = value
284 && matches!(op, ConfigOp::Set | ConfigOp::Add)
285 {
286 args.push(v.to_string());
287 }
288 Ok(git_cmd(caller, dir, env, None, args))
289}
290
291pub enum ArchiveFormatArg {
292 Tar,
293 TarGz,
294 TarBz2,
295 Zip,
296}
297
298pub fn build_archive_cmd(
299 caller: &str,
300 dir: &Path,
301 env: Env,
302 format: ArchiveFormatArg,
303 prefix: Option<&str>,
304 commit: &str,
305 paths: &[String],
306) -> Result<Cmd, BabyError> {
307 validate_revision(commit)?;
308 if let Some(p) = prefix {
309 reject_starts_with_dash(p, "archive prefix")?;
310 if p.contains('\0') || p.contains('\n') || p.contains('\r') {
311 return Err(BabyError::InvalidGitArg(format!(
312 "archive prefix contains control character: `{}`",
313 p
314 )));
315 }
316 }
317 for p in paths {
318 reject_starts_with_dash(p, "archive path")?;
319 }
320 let mut args = vec!["archive".to_string()];
321 match format {
322 ArchiveFormatArg::Tar => args.push("--format=tar".to_string()),
323 ArchiveFormatArg::TarGz => args.push("--format=tar.gz".to_string()),
324 ArchiveFormatArg::TarBz2 => args.push("--format=tar.bz2".to_string()),
325 ArchiveFormatArg::Zip => args.push("--format=zip".to_string()),
326 }
327 if let Some(p) = prefix {
328 args.push(format!("--prefix={}", p));
329 }
330 args.push("--end-of-options".to_string());
331 args.push(commit.to_string());
332 if !paths.is_empty() {
333 args.push("--".to_string());
334 for p in paths {
335 args.push(p.clone());
336 }
337 }
338 Ok(git_cmd(caller, dir, env, None, args))
339}
340pub enum RemoteOp {
341 Add,
342 Remove,
343 GetUrl,
344 Show,
345}
346
347pub enum RemoteMirror {
348 Default,
349 Push,
350 Fetch,
351}
352
353pub fn build_remote_op_cmd(
354 caller: &str,
355 dir: &std::path::Path,
356 env: Env,
357 op: RemoteOp,
358 name: &str,
359 url: Option<&str>,
360 mirror: RemoteMirror,
361) -> Result<Cmd, BabyError> {
362 reject_starts_with_dash(name, "remote name")?;
363 if name.is_empty() {
364 return Err(BabyError::InvalidGitArg("remote name is empty".to_string()));
365 }
366 if let Some(u) = url {
367 validate_remote_url(u)?;
368 }
369 let mut args = vec!["remote".to_string()];
370 match op {
371 RemoteOp::Add => args.push("add".to_string()),
372 RemoteOp::Remove => args.push("rm".to_string()),
373 RemoteOp::GetUrl => args.push("get-url".to_string()),
374 RemoteOp::Show => args.push("show".to_string()),
375 }
376 match mirror {
377 RemoteMirror::Default => {}
378 RemoteMirror::Push => args.push("--mirror=push".to_string()),
379 RemoteMirror::Fetch => args.push("--mirror=fetch".to_string()),
380 }
381 args.push("--end-of-options".to_string());
382 args.push(name.to_string());
383 if let Some(u) = url {
384 args.push(u.to_string());
385 }
386 Ok(git_cmd(caller, dir, env, None, args))
387}
388
389pub fn build_ls_remote_cmd(
390 caller: &str,
391 dir: &std::path::Path,
392 env: Env,
393 url: &str,
394 refs: &[String],
395) -> Result<Cmd, BabyError> {
396 validate_remote_url(url)?;
397 for r in refs {
398 reject_starts_with_dash(r, "ref pattern")?;
399 }
400 let mut args = vec![
401 "ls-remote".to_string(),
402 "--end-of-options".to_string(),
403 url.to_string(),
404 ];
405 for r in refs {
406 args.push(r.clone());
407 }
408 Ok(git_cmd(caller, dir, env, None, args))
409}
410
411pub fn build_init_cmd(
412 caller: &str,
413 dir: &std::path::Path,
414 env: Env,
415 path: &str,
416 bare: bool,
417 object_format: Option<&str>,
418) -> Result<Cmd, BabyError> {
419 if path.starts_with('-') {
420 return Err(BabyError::InvalidGitArg(format!(
421 "init path must not start with '-': `{}`",
422 path
423 )));
424 }
425 if path.contains('\0') {
426 return Err(BabyError::InvalidGitArg(format!(
427 "init path contains NUL: `{}`",
428 path
429 )));
430 }
431 if let Some(of) = object_format {
432 reject_starts_with_dash(of, "object-format")?;
433 }
434 let mut args = vec!["init".to_string()];
435 if bare {
436 args.push("--bare".to_string());
437 }
438 if let Some(of) = object_format {
439 args.push(format!("--object-format={}", of));
440 }
441 args.push("--end-of-options".to_string());
442 args.push(path.to_string());
443 Ok(git_cmd(caller, dir, env, None, args))
444}
445
446pub fn build_fast_import_cmd(caller: &str, dir: &std::path::Path, env: Env) -> Cmd {
447 let args = vec![
448 "fast-import".to_string(),
449 "--force".to_string(),
450 "--done".to_string(),
451 ];
452 git_cmd(caller, dir, env, None, args)
453}
454
455pub fn build_gc_cmd(caller: &str, dir: &std::path::Path, env: Env, prune_now: bool) -> Cmd {
456 let mut args = vec!["gc".to_string()];
457 if prune_now {
458 args.push("--prune=now".to_string());
459 }
460 git_cmd(caller, dir, env, None, args)
461}
462
463pub fn build_pack_refs_cmd(caller: &str, dir: &std::path::Path, env: Env) -> Cmd {
464 let args = vec!["pack-refs".to_string(), "--all".to_string()];
465 git_cmd(caller, dir, env, None, args)
466}
467
468pub fn build_fetch_cmd(
469 caller: &str,
470 dir: &std::path::Path,
471 env: Env,
472 remote_url: &str,
473 refspecs: &[String],
474 prune: bool,
475 tags: bool,
476) -> Result<Cmd, BabyError> {
477 validate_remote_url(remote_url)?;
478 for r in refspecs {
479 reject_starts_with_dash(r, "refspec")?;
480 }
481 let mut args = vec!["fetch".to_string()];
482 if prune {
483 args.push("--prune".to_string());
484 }
485 if !tags {
486 args.push("--no-tags".to_string());
487 }
488 args.push("--end-of-options".to_string());
489 args.push(remote_url.to_string());
490 for r in refspecs {
491 args.push(r.clone());
492 }
493 Ok(git_cmd(caller, dir, env, None, args))
494}
495
496pub fn build_push_cmd(
497 caller: &str,
498 dir: &std::path::Path,
499 env: Env,
500 remote_url: &str,
501 refspecs: &[String],
502 force: bool,
503) -> Result<Cmd, BabyError> {
504 validate_remote_url(remote_url)?;
505 for r in refspecs {
506 reject_starts_with_dash(r, "refspec")?;
507 }
508 let mut args = vec!["push".to_string()];
509 if force {
510 args.push("--force".to_string());
511 }
512 args.push("--end-of-options".to_string());
513 args.push(remote_url.to_string());
514 for r in refspecs {
515 args.push(r.clone());
516 }
517 Ok(git_cmd(caller, dir, env, None, args))
518}
Added lib/gitbaby/src/share/env.rs lib/gitbaby/src/share/env.rs +80 −0 View lib/gitbaby/src/share/env.rs
@@ -0,0 +1,80 @@
1use std::path::PathBuf;
2
3use crate::command::env::Env;
4
5/// Build an env for a git child process by **inheriting the whole process
6/// environment**.
7///
8/// # Security
9///
10/// This forwards every current-process variable (including `GIT_SSH_COMMAND`,
11/// `GIT_ASKPASS`, `LD_PRELOAD`, `*_TOKEN`, `*_SECRET`, proxies, …) into the
12/// spawned `git` process. Prefer [`build_env_safe`] unless a specific variable
13/// set must reach git (e.g. credential helpers you explicitly control).
14pub fn build_env(alternates: &[PathBuf]) -> Env {
15 let mut env = Env::new().with_current_env();
16 set_alternate_object_dirs(&mut env, alternates);
17 env
18}
19
20/// Build an env for a git child process from an explicit allow-list of the
21/// process environment plus the alternate object directories.
22///
23/// Only `PATH`, `HOME`, `LANG`, `LC_ALL`, `LC_CTYPE`, `USER`, `LOGNAME`, `TZ`
24/// and `SSH_AUTH_SOCK` are forwarded; everything else is dropped. This keeps
25/// CI tokens, cloud credentials, proxies and `GIT_*` overrides (such as a
26/// hostile `GIT_SSH_COMMAND` or `GIT_DIR` set by an attacker) out of the
27/// subprocess.
28pub fn build_env_safe(alternates: &[PathBuf]) -> Env {
29 const ALLOWED: &[&str] = &[
30 "PATH",
31 "HOME",
32 "LANG",
33 "LC_ALL",
34 "LC_CTYPE",
35 "USER",
36 "LOGNAME",
37 "TZ",
38 "SSH_AUTH_SOCK",
39 "XDG_RUNTIME_DIR",
40 ];
41 let mut env = Env::new();
42 for (k, v) in std::env::vars() {
43 if ALLOWED.iter().any(|a| *a == k) {
44 env.set(k, v);
45 }
46 }
47 // Explicitly drop git-override variables that could redirect where git
48 // reads config/objects from or how it shells out.
49 for dangerous in [
50 "GIT_DIR",
51 "GIT_WORK_TREE",
52 "GIT_SSH_COMMAND",
53 "GIT_SSH",
54 "GIT_ASKPASS",
55 "GIT_CONFIG",
56 "GIT_CONFIG_GLOBAL",
57 "GIT_CONFIG_SYSTEM",
58 "GIT_OBJECT_DIRECTORY",
59 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
60 "GIT_EXEC_PATH",
61 "GIT_TEMPLATE_DIR",
62 "GIT_INDEX_FILE",
63 "GIT_CEILING_DIRECTORIES",
64 "LD_PRELOAD",
65 "DYLD_INSERT_LIBRARIES",
66 ] {
67 env.unset(dangerous);
68 }
69 set_alternate_object_dirs(&mut env, alternates);
70 env
71}
72
73pub fn set_alternate_object_dirs(env: &mut Env, alternates: &[PathBuf]) {
74 if !alternates.is_empty()
75 && let Ok(joined) = std::env::join_paths(alternates)
76 && let Ok(s) = joined.into_string()
77 {
78 env.set("GIT_ALTERNATE_OBJECT_DIRECTORIES", s);
79 }
80}
Added lib/gitbaby/src/share/error.rs lib/gitbaby/src/share/error.rs +15 −0 View lib/gitbaby/src/share/error.rs
@@ -0,0 +1,15 @@
1use std::error::Error;
2
3use crate::error::BabyError;
4
5pub fn facade_error<E: Error + Send + Sync + 'static>(e: E) -> BabyError {
6 BabyError::Custom(format!("facade error: {}", e))
7}
8
9pub fn spawn_failure(prog: &str, source: std::io::Error) -> BabyError {
10 BabyError::Spawn {
11 prog: prog.to_string(),
12 source,
13 stderr: String::new(),
14 }
15}
Added lib/gitbaby/src/share/mod.rs lib/gitbaby/src/share/mod.rs +19 −0 View lib/gitbaby/src/share/mod.rs
@@ -0,0 +1,19 @@
1pub mod cmd;
2pub mod env;
3pub mod error;
4pub mod path;
5
6pub use cmd::{
7 ArchiveFormatArg, CatFileSubcommand, ConfigOp, ConfigScope, RemoteMirror, RemoteOp,
8 build_archive_cmd, build_config_cmd, build_fast_import_cmd, build_fetch_cmd,
9 build_for_each_ref_args, build_gc_cmd, build_init_cmd, build_ls_remote_cmd,
10 build_pack_refs_cmd, build_push_cmd, build_remote_op_cmd, classify_spawn_error,
11 for_each_ref_format, git_cat_file_cmd, git_cmd, git_hash_object_cmd, is_dangerous_config_key,
12 reject_starts_with_dash, validate_config_key, validate_remote_url, validate_revision,
13};
14pub use env::{build_env, build_env_safe, set_alternate_object_dirs};
15pub use error::{facade_error, spawn_failure};
16pub use path::{
17 parse_git_mode, parse_object_id, parse_object_map, to_string_lossy_owned,
18 validate_local_no_escape, validate_ref_name, validate_ref_pattern, validate_revision_non_empty,
19};
Added lib/gitbaby/src/share/path.rs lib/gitbaby/src/share/path.rs +127 −0 View lib/gitbaby/src/share/path.rs
@@ -0,0 +1,127 @@
1use std::path::{Component, Path};
2
3use crate::error::BabyError;
4
5pub fn validate_local_no_escape(path: &Path) -> Result<(), BabyError> {
6 if path.as_os_str().is_empty() {
7 return Ok(());
8 }
9 if let Some(s) = path.to_str() {
10 if s.starts_with('-') {
11 return Err(BabyError::PathEscapesRepository);
12 }
13 } else {
14 return Err(BabyError::PathEscapesRepository);
15 }
16 if path.is_absolute() {
17 return Err(BabyError::PathEscapesRepository);
18 }
19 for comp in path.components() {
20 if matches!(comp, Component::ParentDir) {
21 return Err(BabyError::PathEscapesRepository);
22 }
23 }
24 Ok(())
25}
26
27pub fn validate_revision_non_empty(rev: &str) -> Result<(), BabyError> {
28 if rev.is_empty() {
29 return Err(BabyError::RevisionEmpty);
30 }
31 Ok(())
32}
33
34pub fn validate_ref_name(name: &str) -> Result<(), BabyError> {
35 if name.is_empty() || name == "@" {
36 return Err(BabyError::InvalidRefName(name.to_string()));
37 }
38 if name.starts_with('-') {
39 return Err(BabyError::InvalidRefName(name.to_string()));
40 }
41 if name.starts_with('/') || name.ends_with('/') {
42 return Err(BabyError::InvalidRefName(name.to_string()));
43 }
44 if name.starts_with('.') || name.ends_with('.') {
45 return Err(BabyError::InvalidRefName(name.to_string()));
46 }
47 if name.contains("..") || name.contains("//") || name.contains("@{") {
48 return Err(BabyError::InvalidRefName(name.to_string()));
49 }
50 if name.contains(".lock/") || name.ends_with(".lock") {
51 return Err(BabyError::InvalidRefName(name.to_string()));
52 }
53 if name.contains("/.") {
54 return Err(BabyError::InvalidRefName(name.to_string()));
55 }
56 for ch in name.chars() {
57 let b = ch as u32;
58 if b < 0o40 || b == 0o177 {
59 return Err(BabyError::InvalidRefName(name.to_string()));
60 }
61 match ch {
62 ' ' | '~' | '^' | ':' | '?' | '*' | '[' | ']' => {
63 return Err(BabyError::InvalidRefName(name.to_string()));
64 }
65 _ => {}
66 }
67 }
68 Ok(())
69}
70
71pub fn validate_ref_pattern(pattern: &str) -> Result<(), BabyError> {
72 if pattern.is_empty() {
73 return Err(BabyError::InvalidRefName(pattern.to_string()));
74 }
75 if pattern.starts_with('-') {
76 return Err(BabyError::InvalidRefName(pattern.to_string()));
77 }
78 if pattern.contains("..") || pattern.contains("//") || pattern.contains("@{") {
79 return Err(BabyError::InvalidRefName(pattern.to_string()));
80 }
81 for ch in pattern.chars() {
82 let b = ch as u32;
83 if b < 0o40 || b == 0o177 {
84 return Err(BabyError::InvalidRefName(pattern.to_string()));
85 }
86 match ch {
87 ' ' | '~' | '^' | ':' => {
88 return Err(BabyError::InvalidRefName(pattern.to_string()));
89 }
90 _ => {}
91 }
92 }
93 Ok(())
94}
95
96pub fn to_string_lossy_owned(path: &Path) -> String {
97 path.to_string_lossy().into_owned()
98}
99
100pub fn parse_git_mode(octal: &str) -> Result<u32, BabyError> {
101 u32::from_str_radix(octal, 8)
102 .map_err(|_| BabyError::Custom(format!("invalid git file mode `{}`", octal)))
103}
104
105pub fn parse_object_id(hex: &str) -> Result<gix::ObjectId, BabyError> {
106 gix::ObjectId::from_hex(hex.as_bytes())
107 .map_err(|source| BabyError::Custom(format!("invalid object id `{}`: {}", hex, source)))
108}
109pub fn parse_object_map(content: &str) -> Result<Vec<(gix::ObjectId, gix::ObjectId)>, BabyError> {
110 let mut pairs = Vec::new();
111 for raw_line in content.lines() {
112 let line = raw_line.trim();
113 if line.is_empty()
114 || line.starts_with('#')
115 || line.starts_with("old new")
116 {
117 continue;
118 }
119 let (old_hex, new_hex) = line
120 .split_once(' ')
121 .ok_or_else(|| BabyError::Custom(format!("object map line missing space: {}", line)))?;
122 let old = parse_object_id(old_hex)?;
123 let new = parse_object_id(new_hex)?;
124 pairs.push((old, new));
125 }
126 Ok(pairs)
127}
Added lib/gitbaby/src/submodule/helper.rs lib/gitbaby/src/submodule/helper.rs +98 −0 View lib/gitbaby/src/submodule/helper.rs
@@ -0,0 +1,98 @@
1use std::path::Path;
2
3use crate::command::env::Env;
4use crate::error::BabyError;
5use crate::submodule::types::SubModule;
6
7pub async fn fetch_gitmodules_blob(
8 read_blob_at_path: impl FnOnce(&str) -> Result<Vec<u8>, BabyError>,
9) -> Result<Option<Vec<u8>>, BabyError> {
10 match read_blob_at_path(".gitmodules") {
11 Ok(bytes) => Ok(Some(bytes)),
12 Err(BabyError::BlobNotFound { .. }) => Ok(None),
13 Err(e) => Err(e),
14 }
15}
16
17pub fn parse_blob_to_submodules(bytes: &[u8]) -> Result<Vec<SubModule>, BabyError> {
18 let text = std::str::from_utf8(bytes).map_err(|e| BabyError::SubModuleParse {
19 line: 0,
20 reason: format!("invalid UTF-8: {}", e),
21 })?;
22 parse_gitmodules(text)
23}
24
25pub fn parse_gitmodules(content: &str) -> Result<Vec<SubModule>, BabyError> {
26 let mut subs: Vec<SubModule> = Vec::new();
27 let mut current: Option<SubModule> = None;
28 for (idx, raw_line) in content.lines().enumerate() {
29 let line_no = idx as u64 + 1;
30 let line = raw_line.trim();
31 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
32 continue;
33 }
34 if line.starts_with('[') {
35 if let Some(s) = current.take() {
36 subs.push(s);
37 }
38 let end_pos = line.rfind(']').ok_or_else(|| BabyError::SubModuleParse {
39 line: line_no,
40 reason: format!("unterminated section header `{}`", line),
41 })?;
42 let inside = &line[1..end_pos];
43 let header = inside
44 .split(' ')
45 .next()
46 .ok_or_else(|| BabyError::SubModuleParse {
47 line: line_no,
48 reason: format!("malformed section header `{}`", line),
49 })?;
50 if header != "submodule" {
51 current = None;
52 continue;
53 }
54 let rest = inside
55 .strip_prefix("submodule")
56 .map(|r| r.trim_start())
57 .ok_or_else(|| BabyError::SubModuleParse {
58 line: line_no,
59 reason: format!("malformed submodule header `{}`", line),
60 })?;
61 let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
62 current = Some(SubModule {
63 name,
64 path: String::new(),
65 url: String::new(),
66 branch: String::new(),
67 });
68 continue;
69 }
70 let Some(ref mut s) = current else {
71 continue;
72 };
73 let (k, v) = line
74 .split_once('=')
75 .ok_or_else(|| BabyError::SubModuleParse {
76 line: line_no,
77 reason: format!("expected `key = value`, got `{}`", line),
78 })?;
79 let key = k.trim();
80 let value = v.trim().trim_matches('"').trim_matches('\'').to_string();
81 match key {
82 "path" => s.path = value,
83 "url" => s.url = value,
84 "branch" => s.branch = value,
85 _ => {}
86 }
87 }
88 if let Some(s) = current.take() {
89 subs.push(s);
90 }
91 Ok(subs)
92}
93
94pub fn lookup<'a>(modules: &'a [SubModule], name: &str) -> Option<&'a SubModule> {
95 modules.iter().find(|s| s.name == name)
96}
97
98pub fn _unused_dir_marker(_dir: &Path, _env: &Env) {}
Added lib/gitbaby/src/submodule/mod.rs lib/gitbaby/src/submodule/mod.rs +5 −0 View lib/gitbaby/src/submodule/mod.rs
@@ -0,0 +1,5 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::SubModule;
Added lib/gitbaby/src/submodule/types.rs lib/gitbaby/src/submodule/types.rs +9 −0 View lib/gitbaby/src/submodule/types.rs
@@ -0,0 +1,9 @@
1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
4pub struct SubModule {
5 pub name: String,
6 pub path: String,
7 pub url: String,
8 pub branch: String,
9}
Added lib/gitbaby/src/submodule/usecase.rs lib/gitbaby/src/submodule/usecase.rs +95 −0 View lib/gitbaby/src/submodule/usecase.rs
@@ -0,0 +1,95 @@
1use std::path::Path;
2
3use crate::GitBaby;
4use crate::error::BabyError;
5use crate::share;
6use crate::submodule::helper;
7use crate::submodule::types::SubModule;
8
9async fn read_blob_at_revision(
10 gitbaby: &GitBaby,
11 commit: &str,
12 blob_path: &str,
13) -> Result<Option<Vec<u8>>, BabyError> {
14 share::validate_revision(commit)?;
15 share::validate_local_no_escape(Path::new(blob_path))?;
16 let dir = gitbaby
17 .facade
18 .git_repo_dir()
19 .await
20 .map_err(share::facade_error)?;
21 let alternates = gitbaby
22 .facade
23 .git_alternate_object_directories()
24 .await
25 .map_err(share::facade_error)?;
26 let env = share::build_env_safe(&alternates);
27 let args = vec![
28 "cat-file".to_string(),
29 "blob".to_string(),
30 "--end-of-options".to_string(),
31 format!("{}:{}", commit, blob_path),
32 ];
33 let mut cmd = share::git_cmd("gitbaby::submodule", &dir, env, None, args);
34 match cmd.run().await {
35 Ok(output) => {
36 if !output.status.success() {
37 let stderr = String::from_utf8_lossy(&output.stderr);
38 if stderr.contains("not a tree")
39 || stderr.contains("does not exist")
40 || stderr.contains("bad revision")
41 || stderr.contains("could not read")
42 {
43 return Ok(None);
44 }
45 return Err(BabyError::SubModuleParse {
46 line: 0,
47 reason: format!("cat-file blob failed: {}", stderr.trim()),
48 });
49 }
50 Ok(Some(output.stdout))
51 }
52 Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => {
53 if stderr.contains("not a tree")
54 || stderr.contains("does not exist")
55 || stderr.contains("bad revision")
56 || stderr.contains("could not read")
57 {
58 Ok(None)
59 } else {
60 Err(BabyError::SubModuleParse {
61 line: 0,
62 reason: format!("cat-file blob failed: {}", stderr.trim()),
63 })
64 }
65 }
66 Err(e) => Err(BabyError::from(e)),
67 }
68}
69
70impl GitBaby {
71 pub async fn list_submodules(&self, commit: &str) -> Result<Vec<SubModule>, BabyError> {
72 share::validate_revision_non_empty(commit)?;
73 match read_blob_at_revision(self, commit, ".gitmodules").await? {
74 Some(bytes) => helper::parse_blob_to_submodules(&bytes),
75 None => Ok(Vec::new()),
76 }
77 }
78
79 pub async fn get_submodule(
80 &self,
81 commit: &str,
82 name: &str,
83 ) -> Result<Option<SubModule>, BabyError> {
84 share::validate_revision_non_empty(commit)?;
85 if name.is_empty() {
86 return Err(BabyError::Custom(
87 "submodule name must not be empty".to_string(),
88 ));
89 }
90 let subs = self.list_submodules(commit).await?;
91 Ok(helper::lookup(&subs, name).cloned())
92 }
93}
94
95fn _unused(_p: &Path) {}
Added lib/gitbaby/src/tags/helper.rs lib/gitbaby/src/tags/helper.rs +264 −0 View lib/gitbaby/src/tags/helper.rs
@@ -0,0 +1,264 @@
1use std::path::Path;
2
3use crate::command::cmd::Cmd;
4use crate::command::env::Env;
5use crate::error::BabyError;
6use crate::share;
7
8use super::types::{
9 CreateTagOptions, ListTagsOptions, SigningFormat, SigningKey, TagType, TagVerifyResult,
10};
11
12pub const CALLER: &str = "gitbaby::tags";
13
14pub fn validate_list_tags_options(opts: &ListTagsOptions) -> Result<(), BabyError> {
15 for p in &opts.patterns {
16 if !p.starts_with("refs/tags/") {
17 return Err(BabyError::InvalidTagName(p.clone()));
18 }
19 share::validate_ref_pattern(p)?;
20 }
21 Ok(())
22}
23
24pub fn validate_create_tag_options(opts: &CreateTagOptions) -> Result<(), BabyError> {
25 if opts.name.is_empty() {
26 return Err(BabyError::InvalidTagName(opts.name.clone()));
27 }
28 share::validate_ref_name(&format!("refs/tags/{}", opts.name))?;
29 if opts.target.is_empty() {
30 return Err(BabyError::RevisionEmpty);
31 }
32 Ok(())
33}
34
35pub fn build_list_tags_cmd(dir: &Path, env: Env, opts: &ListTagsOptions) -> Cmd {
36 let args = share::build_for_each_ref_args(
37 &opts.patterns,
38 Some("creatordate"),
39 None,
40 None,
41 None,
42 &[],
43 true,
44 );
45 share::git_cmd(CALLER, dir, env, None, args)
46}
47
48pub fn build_get_tag_id_cmd(dir: &Path, env: Env, name: &str) -> Cmd {
49 share::git_cmd(
50 CALLER,
51 dir,
52 env,
53 None,
54 vec![
55 "show-ref".to_string(),
56 "--tags".to_string(),
57 "--".to_string(),
58 name.to_string(),
59 ],
60 )
61}
62
63pub fn build_cat_tag_cmd(dir: &Path, env: Env, oid: &str) -> Cmd {
64 share::git_cmd(
65 CALLER,
66 dir,
67 env,
68 None,
69 vec!["cat-file".to_string(), "-p".to_string(), oid.to_string()],
70 )
71}
72
73pub fn build_create_lightweight_tag_cmd(dir: &Path, env: Env, opts: &CreateTagOptions) -> Cmd {
74 share::git_cmd(
75 CALLER,
76 dir,
77 env,
78 None,
79 vec!["tag".to_string(), opts.name.clone(), opts.target.clone()],
80 )
81}
82
83pub fn build_create_annotated_tag_cmd(dir: &Path, env: Env, opts: &CreateTagOptions) -> Cmd {
84 let msg = opts.message.clone().unwrap_or_default();
85 share::git_cmd(
86 CALLER,
87 dir,
88 env,
89 None,
90 vec![
91 "tag".to_string(),
92 "-a".to_string(),
93 "-m".to_string(),
94 msg,
95 opts.name.clone(),
96 opts.target.clone(),
97 ],
98 )
99}
100
101pub fn build_create_signed_tag_cmd(
102 dir: &Path,
103 env: Env,
104 opts: &CreateTagOptions,
105 key: &SigningKey,
106) -> Cmd {
107 let (flag, key_arg) = match key.format {
108 SigningFormat::Ssh => ("-s".to_string(), None),
109 _ => ("-u".to_string(), Some(key.key_id.clone())),
110 };
111 let msg = opts.message.clone().unwrap_or_default();
112 let mut args = vec![
113 "tag".to_string(),
114 flag,
115 "-m".to_string(),
116 msg,
117 opts.name.clone(),
118 opts.target.clone(),
119 ];
120 if let Some(ka) = key_arg {
121 args.insert(3, ka);
122 }
123 share::git_cmd(CALLER, dir, env, None, args)
124}
125
126pub fn build_verify_tag_cmd(dir: &Path, env: Env, name: &str) -> Cmd {
127 share::git_cmd(
128 CALLER,
129 dir,
130 env,
131 None,
132 vec![
133 "verify-tag".to_string(),
134 "--raw".to_string(),
135 name.to_string(),
136 ],
137 )
138}
139
140pub fn build_delete_tag_cmd(dir: &Path, env: Env, name: &str) -> Cmd {
141 share::git_cmd(
142 CALLER,
143 dir,
144 env,
145 None,
146 vec!["tag".to_string(), "-d".to_string(), name.to_string()],
147 )
148}
149
150pub fn classify_stderr(stderr: &str, name: &str) -> BabyError {
151 let s = stderr.trim();
152 if s.contains("already exists") {
153 return BabyError::TagAlreadyExists(name.to_string());
154 }
155 if s.contains("not found") || s.contains("does not exist") {
156 return BabyError::TagNotFound(name.to_string());
157 }
158 if s.contains("invalid") && (s.contains("tag") || s.contains("key")) {
159 return BabyError::InvalidTagName(name.to_string());
160 }
161 BabyError::Spawn {
162 prog: "git".to_string(),
163 source: std::io::Error::other(s.to_string()),
164 stderr: s.to_string(),
165 }
166}
167
168pub fn classify_cmd_error(err: crate::command::error::CmdError, name: &str) -> BabyError {
169 use crate::command::error::CmdError;
170 match err {
171 CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr, name),
172 CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
173 other => BabyError::from(other),
174 }
175}
176
177pub fn parse_show_ref_tag(output: &str) -> Result<(String, String), BabyError> {
178 for line in output.lines() {
179 let s = line.trim();
180 if s.is_empty() {
181 continue;
182 }
183 let mut parts = s.split_whitespace();
184 let oid = match parts.next() {
185 Some(s) => s.to_string(),
186 None => continue,
187 };
188 let full = match parts.next() {
189 Some(s) => s.to_string(),
190 None => continue,
191 };
192 if full == "refs/tags/^{}" || full.ends_with("/^{}") {
193 continue;
194 }
195 if full.starts_with("refs/tags/") {
196 return Ok((oid, full));
197 }
198 }
199 Err(BabyError::TagNotFound(output.trim().to_string()))
200}
201
202pub fn parse_cat_tag_object(output: &str) -> (TagType, Option<ObjectId>) {
203 let mut tag_type = TagType::Lightweight;
204 let mut target: Option<ObjectId> = None;
205 for line in output.lines() {
206 if let Some(rest) = line.strip_prefix("type ") {
207 if rest.trim() == "tag" {
208 tag_type = TagType::Annotated;
209 }
210 } else if let Some(rest) = line.strip_prefix("object ")
211 && let Ok(oid) = gix::ObjectId::from_hex(rest.trim().as_bytes())
212 {
213 target = Some(oid);
214 }
215 }
216 (tag_type, target)
217}
218
219pub fn parse_verify_tag_output(output: &str, stderr: &str) -> TagVerifyResult {
220 let combined = format!("{}\n{}", output, stderr);
221 let mut valid = false;
222 let mut signer: Option<String> = None;
223 let mut fingerprint: Option<String> = None;
224 let mut error_message: Option<String> = None;
225
226 if let Some(idx) = combined.find("error") {
227 let line_end = combined[idx..].find('\n').unwrap_or(combined.len() - idx);
228 error_message = Some(combined[idx..idx + line_end].trim().to_string());
229 }
230
231 for line in combined.lines() {
232 if line.starts_with("good ") || line.starts_with("valid ") {
233 valid = true;
234 }
235 if let Some(idx) = line.find("signer \"") {
236 let rest = &line[idx + 8..];
237 if let Some(end) = rest.find('"') {
238 signer = Some(rest[..end].to_string());
239 }
240 }
241 if let Some(idx) = line.find("fingerprint ") {
242 let rest = &line[idx + 12..];
243 let trimmed = rest.trim();
244 if !trimmed.is_empty() {
245 fingerprint = Some(
246 trimmed
247 .split_whitespace()
248 .next()
249 .unwrap_or(trimmed)
250 .to_string(),
251 );
252 }
253 }
254 }
255
256 TagVerifyResult {
257 valid,
258 signer,
259 fingerprint,
260 error_message,
261 }
262}
263
264use gix::ObjectId;
Added lib/gitbaby/src/tags/mod.rs lib/gitbaby/src/tags/mod.rs +7 −0 View lib/gitbaby/src/tags/mod.rs
@@ -0,0 +1,7 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{
6 CreateTagOptions, ListTagsOptions, SigningFormat, SigningKey, TagInfo, TagType, TagVerifyResult,
7};
Added lib/gitbaby/src/tags/types.rs lib/gitbaby/src/tags/types.rs +51 −0 View lib/gitbaby/src/tags/types.rs
@@ -0,0 +1,51 @@
1use gix::ObjectId;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5pub enum SigningFormat {
6 Ssh,
7 OpenGpg,
8 X509,
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SigningKey {
13 pub format: SigningFormat,
14 pub key_id: String,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum TagType {
19 Lightweight,
20 Annotated,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct TagInfo {
25 pub name: String,
26 pub sha: ObjectId,
27 pub target_sha: ObjectId,
28 pub tag_type: TagType,
29 pub tagger: Option<crate::commit::types::Signature>,
30 pub message: Option<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CreateTagOptions {
35 pub name: String,
36 pub target: String,
37 pub message: Option<String>,
38}
39
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41pub struct ListTagsOptions {
42 pub patterns: Vec<String>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct TagVerifyResult {
47 pub valid: bool,
48 pub signer: Option<String>,
49 pub fingerprint: Option<String>,
50 pub error_message: Option<String>,
51}
Added lib/gitbaby/src/tags/usecase.rs lib/gitbaby/src/tags/usecase.rs +261 −0 View lib/gitbaby/src/tags/usecase.rs
@@ -0,0 +1,261 @@
1use crate::GitBaby;
2use crate::commit::helper as commit_helper;
3use crate::commit::types::Signature;
4use crate::error::BabyError;
5use crate::refs::types::{ListRefsOptions, RefType};
6use crate::share;
7use crate::tags::helper;
8use crate::tags::types::{
9 CreateTagOptions, ListTagsOptions, SigningKey, TagInfo, TagType, TagVerifyResult,
10};
11
12impl GitBaby {
13 pub async fn list_tags(&self, opts: ListTagsOptions) -> Result<Vec<TagInfo>, BabyError> {
14 helper::validate_list_tags_options(&opts)?;
15 let refs_opts = ListRefsOptions {
16 patterns: opts.patterns.clone(),
17 peel_tags: true,
18 ..Default::default()
19 };
20 let refs = self.list_refs(refs_opts).await?;
21 let mut tags = Vec::new();
22 for r in refs {
23 if !matches!(r.ref_type, RefType::Tag) {
24 continue;
25 }
26 let peeled = r.peeled.unwrap_or(r.sha);
27 tags.push(TagInfo {
28 name: r
29 .name
30 .strip_prefix("refs/tags/")
31 .unwrap_or(&r.name)
32 .to_string(),
33 sha: r.sha,
34 target_sha: peeled,
35 tag_type: if r.peeled.is_some() {
36 TagType::Annotated
37 } else {
38 TagType::Lightweight
39 },
40 tagger: None,
41 message: None,
42 });
43 }
44 Ok(tags)
45 }
46
47 pub async fn find_tag(&self, name: &str) -> Result<TagInfo, BabyError> {
48 share::validate_ref_name(&format!("refs/tags/{}", name))?;
49 let mut cmd = self
50 .spawn_tags_cmd(|dir, env| helper::build_get_tag_id_cmd(dir, env, name))
51 .await?;
52 let output = match cmd.run().await {
53 Ok(o) => o,
54 Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => {
55 return Err(helper::classify_stderr(&stderr, name));
56 }
57 Err(e) => return Err(helper::classify_cmd_error(e, name)),
58 };
59 if !output.status.success() {
60 return Err(helper::classify_stderr(
61 &String::from_utf8_lossy(&output.stderr),
62 name,
63 ));
64 }
65 let stdout = String::from_utf8_lossy(&output.stdout);
66 let (oid_str, full_name) = helper::parse_show_ref_tag(&stdout)?;
67 let oid = gix::ObjectId::from_hex(oid_str.as_bytes())
68 .map_err(|e| BabyError::Custom(format!("tag oid parse: {}", e)))?;
69 let mut cmd2 = self
70 .spawn_tags_cmd(|dir, env| helper::build_cat_tag_cmd(dir, env, &oid_str))
71 .await?;
72 let cat_output = cmd2
73 .run()
74 .await
75 .map_err(|e| helper::classify_cmd_error(e, name))?;
76 let cat_stdout = String::from_utf8_lossy(&cat_output.stdout);
77 let (tag_type, target) = helper::parse_cat_tag_object(&cat_stdout);
78 let target_sha = target
79 .ok_or_else(|| BabyError::Custom(format!("tag `{}` has no target object", name)))?;
80 let (tagger, message) = parse_annotated_payload(&cat_stdout);
81 Ok(TagInfo {
82 name: full_name
83 .strip_prefix("refs/tags/")
84 .unwrap_or(&full_name)
85 .trim_end_matches("^{}")
86 .to_string(),
87 sha: oid,
88 target_sha,
89 tag_type,
90 tagger,
91 message,
92 })
93 }
94
95 pub async fn tag_exists(&self, name: &str) -> Result<bool, BabyError> {
96 share::validate_ref_name(&format!("refs/tags/{}", name))?;
97 let mut cmd = self
98 .spawn_tags_cmd(|dir, env| helper::build_get_tag_id_cmd(dir, env, name))
99 .await?;
100 match cmd.run().await {
101 Ok(out) => Ok(out.status.success()),
102 Err(crate::command::error::CmdError::NonZeroExit { stderr, .. }) => {
103 let s = stderr.trim();
104 if s.contains("not found") || s.is_empty() {
105 Ok(false)
106 } else {
107 Err(helper::classify_stderr(s, name))
108 }
109 }
110 Err(e) => Err(helper::classify_cmd_error(e, name)),
111 }
112 }
113
114 pub async fn create_lightweight_tag(&self, opts: CreateTagOptions) -> Result<(), BabyError> {
115 helper::validate_create_tag_options(&opts)?;
116 let mut cmd = self
117 .spawn_tags_cmd(|dir, env| helper::build_create_lightweight_tag_cmd(dir, env, &opts))
118 .await?;
119 let output = cmd
120 .run()
121 .await
122 .map_err(|e| helper::classify_cmd_error(e, &opts.name))?;
123 if !output.status.success() {
124 return Err(helper::classify_stderr(
125 &String::from_utf8_lossy(&output.stderr),
126 &opts.name,
127 ));
128 }
129 let _ = crate::cache::invalidation::hook_ref_updated(self, &opts.name).await;
130 Ok(())
131 }
132
133 pub async fn create_annotated_tag(&self, opts: CreateTagOptions) -> Result<(), BabyError> {
134 helper::validate_create_tag_options(&opts)?;
135 if opts.message.is_none() {
136 return Err(BabyError::Custom(
137 "annotated tag requires message".to_string(),
138 ));
139 }
140 let mut cmd = self
141 .spawn_tags_cmd(|dir, env| helper::build_create_annotated_tag_cmd(dir, env, &opts))
142 .await?;
143 let output = cmd
144 .run()
145 .await
146 .map_err(|e| helper::classify_cmd_error(e, &opts.name))?;
147 if !output.status.success() {
148 return Err(helper::classify_stderr(
149 &String::from_utf8_lossy(&output.stderr),
150 &opts.name,
151 ));
152 }
153 let _ = crate::cache::invalidation::hook_ref_updated(self, &opts.name).await;
154 Ok(())
155 }
156
157 pub async fn create_signed_tag(
158 &self,
159 opts: CreateTagOptions,
160 key: SigningKey,
161 ) -> Result<(), BabyError> {
162 helper::validate_create_tag_options(&opts)?;
163 if opts.message.is_none() {
164 return Err(BabyError::Custom("signed tag requires message".to_string()));
165 }
166 let mut cmd = self
167 .spawn_tags_cmd(|dir, env| helper::build_create_signed_tag_cmd(dir, env, &opts, &key))
168 .await?;
169 let output = cmd
170 .run()
171 .await
172 .map_err(|e| helper::classify_cmd_error(e, &opts.name))?;
173 if !output.status.success() {
174 return Err(helper::classify_stderr(
175 &String::from_utf8_lossy(&output.stderr),
176 &opts.name,
177 ));
178 }
179 let _ = crate::cache::invalidation::hook_ref_updated(self, &opts.name).await;
180 Ok(())
181 }
182
183 pub async fn verify_tag_signature(&self, name: &str) -> Result<TagVerifyResult, BabyError> {
184 share::validate_ref_name(&format!("refs/tags/{}", name))?;
185 let mut cmd = self
186 .spawn_tags_cmd(|dir, env| helper::build_verify_tag_cmd(dir, env, name))
187 .await?;
188 let output = cmd
189 .run()
190 .await
191 .map_err(|e| helper::classify_cmd_error(e, name))?;
192 let stdout = String::from_utf8_lossy(&output.stdout);
193 let stderr = String::from_utf8_lossy(&output.stderr);
194 let result = helper::parse_verify_tag_output(&stdout, &stderr);
195 Ok(result)
196 }
197
198 pub async fn delete_tag(&self, name: &str) -> Result<(), BabyError> {
199 share::validate_ref_name(&format!("refs/tags/{}", name))?;
200 let mut cmd = self
201 .spawn_tags_cmd(|dir, env| helper::build_delete_tag_cmd(dir, env, name))
202 .await?;
203 let output = cmd
204 .run()
205 .await
206 .map_err(|e| helper::classify_cmd_error(e, name))?;
207 if !output.status.success() {
208 return Err(helper::classify_stderr(
209 &String::from_utf8_lossy(&output.stderr),
210 name,
211 ));
212 }
213 let _ = crate::cache::invalidation::hook_ref_updated(self, name).await;
214 Ok(())
215 }
216
217 async fn spawn_tags_cmd<F>(&self, builder: F) -> Result<crate::command::cmd::Cmd, BabyError>
218 where
219 F: FnOnce(&std::path::Path, crate::command::env::Env) -> crate::command::cmd::Cmd,
220 {
221 let dir = self
222 .facade
223 .git_repo_dir()
224 .await
225 .map_err(share::facade_error)?;
226 let alternates = self
227 .facade
228 .git_alternate_object_directories()
229 .await
230 .map_err(share::facade_error)?;
231 let env = share::build_env_safe(&alternates);
232 Ok(builder(&dir, env))
233 }
234}
235
236fn parse_annotated_payload(s: &str) -> (Option<Signature>, Option<String>) {
237 let mut tagger: Option<Signature> = None;
238 let mut message: Option<String> = None;
239 let mut in_message = false;
240 let mut msg_buf = String::new();
241 for line in s.lines() {
242 if in_message {
243 msg_buf.push_str(line);
244 msg_buf.push('\n');
245 continue;
246 }
247 if line.is_empty() {
248 in_message = true;
249 continue;
250 }
251 if let Some(rest) = line.strip_prefix("tagger ")
252 && let Ok(sig) = commit_helper::parse_signature_line(rest)
253 {
254 tagger = Some(sig);
255 }
256 }
257 if !msg_buf.is_empty() {
258 message = Some(msg_buf.trim_end().to_string());
259 }
260 (tagger, message)
261}
Added lib/gitbaby/src/tree/helper.rs lib/gitbaby/src/tree/helper.rs +359 −0 View lib/gitbaby/src/tree/helper.rs
@@ -0,0 +1,359 @@
1use std::path::{Path, PathBuf};
2
3use crate::command::cmd::Cmd;
4use crate::command::env::Env;
5use crate::commit::types::{TreeEntry, TreeEntryMode};
6use crate::error::BabyError;
7use crate::share;
8use crate::tree::types::{DiffTreesOptions, TreeDiff, TreeDiffStatus, WalkTreeOptions};
9
10pub const CALLER: &str = "gitbaby::tree";
11
12pub const EMPTY_TREE_OID_HEX: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
13
14pub fn empty_tree_oid() -> Result<gix::ObjectId, BabyError> {
15 gix::ObjectId::from_hex(EMPTY_TREE_OID_HEX.as_bytes())
16 .map_err(|e| BabyError::Custom(format!("empty tree oid parse: {}", e)))
17}
18
19pub fn build_walk_tree_cmd(
20 dir: &Path,
21 env: Env,
22 revision: &str,
23 path: &Path,
24 opts: &WalkTreeOptions,
25) -> Cmd {
26 let mut args = vec!["ls-tree".to_string()];
27 if opts.recurse {
28 args.push("-r".to_string());
29 }
30 if opts.show_trees {
31 args.push("-t".to_string());
32 }
33 args.push("-l".to_string());
34 args.push(revision.to_string());
35 let path_str = share::to_string_lossy_owned(path);
36 if !path_str.is_empty() && path_str != "." {
37 args.push("--".to_string());
38 args.push(path_str);
39 }
40 share::git_cmd(CALLER, dir, env, None, args)
41}
42
43pub fn build_diff_trees_cmd(
44 dir: &Path,
45 env: Env,
46 old_rev: &str,
47 new_rev: &str,
48 path: &Path,
49 opts: &DiffTreesOptions,
50) -> Cmd {
51 let mut args = vec!["diff-tree".to_string()];
52 if opts.recurse {
53 args.push("-r".to_string());
54 }
55 if opts.detect_renames {
56 args.push("-M".to_string());
57 }
58 if opts.detect_copies {
59 args.push("-C".to_string());
60 }
61 args.push("--no-commit-id".to_string());
62 args.push("-z".to_string());
63 args.push("--diff-filter=ADMRT".to_string());
64 args.push(format!("{}..{}", old_rev, new_rev));
65 let path_str = share::to_string_lossy_owned(path);
66 if !path_str.is_empty() && path_str != "." {
67 args.push("--".to_string());
68 args.push(path_str);
69 }
70 share::git_cmd(CALLER, dir, env, None, args)
71}
72
73pub fn build_commit_tree_cmd(
74 dir: &Path,
75 env: Env,
76 tree_oid: &str,
77 parents: &[String],
78 signing_key_id: Option<&str>,
79 gpg_format: Option<&str>,
80 no_gpg_sign: bool,
81) -> Result<Cmd, BabyError> {
82 share::validate_revision(tree_oid)?;
83 for p in parents {
84 share::validate_revision(p)?;
85 }
86 let mut args = vec![
87 "commit-tree".to_string(),
88 "--end-of-options".to_string(),
89 tree_oid.to_string(),
90 ];
91 for p in parents {
92 args.push("-p".to_string());
93 args.push(p.clone());
94 }
95 if let Some(key_id) = signing_key_id {
96 share::reject_starts_with_dash(key_id, "signing key id")?;
97 if !key_id
98 .chars()
99 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | '='))
100 {
101 return Err(BabyError::InvalidGitArg(format!(
102 "signing key id contains invalid characters: `{}`",
103 key_id
104 )));
105 }
106 if let Some(format) = gpg_format {
107 share::reject_starts_with_dash(format, "gpg format")?;
108 match format {
109 "openpgp" | "x509" | "ssh" => {
110 args.push("-c".to_string());
111 args.push(format!("gpg.format={}", format));
112 }
113 _ => {
114 return Err(BabyError::InvalidGitArg(format!(
115 "unsupported gpg format `{}` (expected openpgp|x509|ssh)",
116 format
117 )));
118 }
119 }
120 }
121 args.push(format!("-S{}", key_id));
122 }
123 if no_gpg_sign {
124 args.push("--no-gpg-sign".to_string());
125 }
126 Ok(share::git_cmd(CALLER, dir, env, None, args))
127}
128
129pub fn build_rev_list_latest_cmd(dir: &Path, env: Env, revision: &str, path: &Path) -> Cmd {
130 let mut args = vec![
131 "rev-list".to_string(),
132 "-1".to_string(),
133 revision.to_string(),
134 ];
135 let path_str = share::to_string_lossy_owned(path);
136 if !path_str.is_empty() && path_str != "." {
137 args.push("--".to_string());
138 args.push(path_str);
139 }
140 share::git_cmd(CALLER, dir, env, None, args)
141}
142
143pub fn build_log_paths_cmd(dir: &Path, env: Env, revision: &str, filter: Option<&str>) -> Cmd {
144 let mut args: Vec<String> = Vec::with_capacity(16);
145 args.extend(
146 [
147 "-c",
148 "log.showSignature=false",
149 "-c",
150 "core.quotepath=off",
151 "log",
152 "-z",
153 "--name-only",
154 "--first-parent",
155 "-m",
156 "--diff-filter=AM",
157 "--no-renames",
158 "--no-decorate",
159 "--pretty=format:COMMIT:%H%x00",
160 revision,
161 ]
162 .into_iter()
163 .map(String::from),
164 );
165 if let Some(f) = filter {
166 args.push("--".to_string());
167 args.push(format!("{}/", f.trim_end_matches('/')));
168 }
169 share::git_cmd(CALLER, dir, env, None, args)
170}
171
172pub fn parse_log_stream(stdout: &[u8]) -> Vec<(gix::ObjectId, String)> {
173 let mut out: Vec<(gix::ObjectId, String)> = Vec::new();
174 let mut current: Option<gix::ObjectId> = None;
175 let mut first_name = true;
176 for chunk in stdout.split(|&b| b == 0) {
177 if chunk.is_empty() {
178 current = None;
179 first_name = true;
180 continue;
181 }
182 if chunk.starts_with(b"COMMIT:") {
183 let hex = std::str::from_utf8(&chunk[7..]).unwrap_or("");
184 current = gix::ObjectId::from_hex(hex.as_bytes()).ok();
185 first_name = true;
186 continue;
187 }
188 if let Some(oid) = current {
189 let bytes = if first_name {
190 chunk.strip_prefix(b"\n").unwrap_or(chunk)
191 } else {
192 chunk
193 };
194 first_name = false;
195 if let Ok(name) = std::str::from_utf8(bytes) {
196 out.push((oid, name.to_string()));
197 }
198 }
199 }
200 out
201}
202
203pub fn classify_stderr(stderr: &str) -> BabyError {
204 let s = stderr.trim();
205 if s.contains("Not a tree") || s.contains("not a tree") {
206 return BabyError::NotATree {
207 revision: s.to_string(),
208 path: PathBuf::new(),
209 };
210 }
211 BabyError::Spawn {
212 prog: "git".to_string(),
213 source: std::io::Error::other(s.to_string()),
214 stderr: s.to_string(),
215 }
216}
217
218pub fn classify_cmd_error(err: crate::command::error::CmdError) -> BabyError {
219 use crate::command::error::CmdError;
220 match err {
221 CmdError::NonZeroExit { stderr, .. } => classify_stderr(&stderr),
222 CmdError::Spawn { prog, source } => share::classify_spawn_error(prog, source),
223 other => BabyError::from(other),
224 }
225}
226
227pub fn parse_rev_list_first(stdout: &[u8]) -> Result<gix::ObjectId, BabyError> {
228 let s = std::str::from_utf8(stdout)
229 .map_err(|e| BabyError::Custom(format!("rev-list invalid utf8: {}", e)))?;
230 let hex = s.split_whitespace().next().unwrap_or("");
231 gix::ObjectId::from_hex(hex.as_bytes())
232 .map_err(|e| BabyError::Custom(format!("rev-list oid parse `{}`: {}", hex, e)))
233}
234
235pub fn parse_commit_tree_oid(stdout: &[u8]) -> Result<gix::ObjectId, BabyError> {
236 let s = std::str::from_utf8(stdout)
237 .map_err(|e| BabyError::Custom(format!("commit-tree invalid utf8: {}", e)))?;
238 gix::ObjectId::from_hex(s.trim().as_bytes())
239 .map_err(|e| BabyError::Custom(format!("commit-tree oid parse `{}`: {}", s.trim(), e)))
240}
241
242pub fn parse_mode(s: &str) -> Option<TreeEntryMode> {
243 if s.is_empty() || s == "0" {
244 return None;
245 }
246 TreeEntryMode::from_octal_str(s)
247}
248
249pub fn parse_diff_tree_z_output(stdout: &[u8]) -> Result<Vec<TreeDiff>, BabyError> {
250 let mut out = Vec::new();
251 let mut iter = stdout.split(|&b| b == 0);
252 while let Some(header) = iter.next() {
253 if header.is_empty() {
254 continue;
255 }
256 let header_str = match std::str::from_utf8(header) {
257 Ok(s) => s,
258 Err(_) => continue,
259 };
260 let mut parts = header_str.split(' ');
261 let old_mode = parts.next().unwrap_or("");
262 let new_mode = parts.next().unwrap_or("");
263 let old_oid_str = parts.next().unwrap_or("");
264 let new_oid_str = parts.next().unwrap_or("");
265 let status_byte = parts.next().unwrap_or("");
266 let old_oid =
267 if old_oid_str.len() >= 7 && old_oid_str.chars().all(|c| c.is_ascii_hexdigit()) {
268 gix::ObjectId::from_hex(old_oid_str.as_bytes()).ok()
269 } else {
270 None
271 };
272 let new_oid =
273 if new_oid_str.len() >= 7 && new_oid_str.chars().all(|c| c.is_ascii_hexdigit()) {
274 gix::ObjectId::from_hex(new_oid_str.as_bytes()).ok()
275 } else {
276 None
277 };
278 let path_bytes = match iter.next() {
279 Some(p) => p,
280 None => break,
281 };
282 let old_path = PathBuf::from(String::from_utf8_lossy(path_bytes).into_owned());
283 let new_path = match status_byte.chars().next().unwrap_or('M') {
284 'R' | 'C' => match iter.next() {
285 Some(p) => PathBuf::from(String::from_utf8_lossy(p).into_owned()),
286 None => old_path.clone(),
287 },
288 _ => old_path.clone(),
289 };
290 let status = match status_byte.chars().next().unwrap_or('M') {
291 'A' => TreeDiffStatus::Added,
292 'D' => TreeDiffStatus::Deleted,
293 'R' => TreeDiffStatus::Renamed,
294 'C' => TreeDiffStatus::Copied,
295 'T' => TreeDiffStatus::TypeChanged,
296 _ => TreeDiffStatus::Modified,
297 };
298 out.push(TreeDiff {
299 old_path: if status == TreeDiffStatus::Added {
300 None
301 } else {
302 Some(old_path.clone())
303 },
304 new_path: if status == TreeDiffStatus::Deleted {
305 None
306 } else {
307 Some(new_path)
308 },
309 old_mode: parse_mode(old_mode),
310 new_mode: parse_mode(new_mode),
311 old_oid,
312 new_oid,
313 status,
314 });
315 }
316 Ok(out)
317}
318
319pub fn parse_ls_tree_entries(bytes: &[u8]) -> Result<Vec<TreeEntry>, BabyError> {
320 let mut out = Vec::new();
321 for (idx, line) in bytes.split(|b| *b == b'\n').enumerate() {
322 if line.is_empty() {
323 continue;
324 }
325 let line_str = std::str::from_utf8(line).map_err(|source| BabyError::CommitParse {
326 line_number: idx as u64 + 1,
327 message: format!("tree entry not utf-8: {}", source),
328 })?;
329 let (meta, name) = line_str
330 .split_once('\t')
331 .ok_or_else(|| BabyError::CommitParse {
332 line_number: idx as u64 + 1,
333 message: format!("tree entry missing name: {}", line_str),
334 })?;
335 let mut parts = meta.split(' ');
336 let mode_str = parts.next().unwrap_or("");
337 let mode = parse_mode(mode_str).ok_or_else(|| BabyError::CommitParse {
338 line_number: idx as u64 + 1,
339 message: format!("unknown tree mode: {}", mode_str),
340 })?;
341 let oid_hex = parts.nth(1).ok_or_else(|| BabyError::CommitParse {
342 line_number: idx as u64 + 1,
343 message: format!("tree entry missing oid: {}", line_str),
344 })?;
345 let oid = gix::ObjectId::from_hex(oid_hex.as_bytes()).map_err(|source| {
346 BabyError::CommitParse {
347 line_number: idx as u64 + 1,
348 message: format!("bad tree oid `{}`: {}", oid_hex, source),
349 }
350 })?;
351 out.push(TreeEntry {
352 oid,
353 name: name.to_string(),
354 mode,
355 size: None,
356 });
357 }
358 Ok(out)
359}
Added lib/gitbaby/src/tree/mod.rs lib/gitbaby/src/tree/mod.rs +5 −0 View lib/gitbaby/src/tree/mod.rs
@@ -0,0 +1,5 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{DiffTreesOptions, TreeDiff, TreeDiffStatus, TreeEntryWithLatest, WalkTreeOptions};
Added lib/gitbaby/src/tree/types.rs lib/gitbaby/src/tree/types.rs +45 −0 View lib/gitbaby/src/tree/types.rs
@@ -0,0 +1,45 @@
1use gix::ObjectId;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5use crate::commit::types::{CommitInfo, TreeEntry, TreeEntryMode};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8pub enum TreeDiffStatus {
9 Added,
10 Deleted,
11 Modified,
12 TypeChanged,
13 Renamed,
14 Copied,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct TreeDiff {
19 pub old_path: Option<PathBuf>,
20 pub new_path: Option<PathBuf>,
21 pub old_mode: Option<TreeEntryMode>,
22 pub new_mode: Option<TreeEntryMode>,
23 pub old_oid: Option<ObjectId>,
24 pub new_oid: Option<ObjectId>,
25 pub status: TreeDiffStatus,
26}
27
28#[derive(Debug, Clone, Default, Serialize, Deserialize)]
29pub struct WalkTreeOptions {
30 pub recurse: bool,
31 pub show_trees: bool,
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35pub struct DiffTreesOptions {
36 pub recurse: bool,
37 pub detect_renames: bool,
38 pub detect_copies: bool,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct TreeEntryWithLatest {
43 pub entry: TreeEntry,
44 pub latest: CommitInfo,
45}
Added lib/gitbaby/src/tree/usecase.rs lib/gitbaby/src/tree/usecase.rs +534 −0 View lib/gitbaby/src/tree/usecase.rs
@@ -0,0 +1,534 @@
1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use gix::ObjectId;
5
6use crate::GitBaby;
7use crate::commit::types::{CommitInfo, Signature, TreeEntry, TreeEntryMode};
8use crate::error::BabyError;
9use crate::share;
10use crate::tree::helper;
11use crate::tree::types::{DiffTreesOptions, TreeDiff, TreeEntryWithLatest, WalkTreeOptions};
12
13impl GitBaby {
14 pub async fn tree_latest_commit(
15 &self,
16 revision: &str,
17 path: &Path,
18 ) -> Result<CommitInfo, BabyError> {
19 share::validate_revision_non_empty(revision)?;
20 share::validate_local_no_escape(path)?;
21 let mut cmd = self
22 .spawn_tree_cmd(|dir, env| {
23 Ok(helper::build_rev_list_latest_cmd(dir, env, revision, path))
24 })
25 .await?;
26 let output = cmd.run().await.map_err(helper::classify_cmd_error)?;
27 if !output.status.success() {
28 return Err(helper::classify_stderr(&String::from_utf8_lossy(
29 &output.stderr,
30 )));
31 }
32 let oid = helper::parse_rev_list_first(&output.stdout)?;
33 self.get_commit(&oid.to_string()).await
34 }
35
36 #[allow(clippy::too_many_arguments)]
37 pub async fn commit_tree(
38 &self,
39 tree_oid: ObjectId,
40 parents: Vec<String>,
41 author: &Signature,
42 committer: &Signature,
43 message: &str,
44 signing_key_id: Option<&str>,
45 gpg_format: Option<&str>,
46 no_gpg_sign: bool,
47 ) -> Result<ObjectId, BabyError> {
48 let mut msg_buf = Vec::new();
49 msg_buf.extend_from_slice(message.as_bytes());
50 if !msg_buf.ends_with(b"\n") {
51 msg_buf.push(b'\n');
52 }
53 let mut cmd = self
54 .spawn_tree_cmd(|dir, env| {
55 let env = set_commit_env(env, author, committer);
56 helper::build_commit_tree_cmd(
57 dir,
58 env,
59 &tree_oid.to_string(),
60 &parents,
61 signing_key_id,
62 gpg_format,
63 no_gpg_sign,
64 )
65 })
66 .await?;
67 if let Err(e) = cmd.feed(&msg_buf).await {
68 return Err(helper::classify_cmd_error(e));
69 }
70 let output = cmd.run().await.map_err(helper::classify_cmd_error)?;
71 if !output.status.success() {
72 return Err(helper::classify_stderr(&String::from_utf8_lossy(
73 &output.stderr,
74 )));
75 }
76 helper::parse_commit_tree_oid(&output.stdout)
77 }
78
79 pub fn empty_tree_oid(&self) -> Result<ObjectId, BabyError> {
80 helper::empty_tree_oid()
81 }
82
83 pub async fn walk_tree(
84 &self,
85 revision: &str,
86 path: &Path,
87 opts: WalkTreeOptions,
88 ) -> Result<Vec<TreeEntry>, BabyError> {
89 share::validate_revision_non_empty(revision)?;
90 share::validate_local_no_escape(path)?;
91 let mut cmd = self
92 .spawn_tree_cmd(|dir, env| {
93 Ok(helper::build_walk_tree_cmd(dir, env, revision, path, &opts))
94 })
95 .await?;
96 let output = cmd.run().await.map_err(helper::classify_cmd_error)?;
97 if !output.status.success() {
98 return Err(helper::classify_stderr(&String::from_utf8_lossy(
99 &output.stderr,
100 )));
101 }
102 crate::commit::helper::parse_tree_entries(&output.stdout)
103 }
104
105 pub async fn tree_entries_with_latest(
106 &self,
107 revision: &str,
108 path: Option<&str>,
109 ) -> Result<Vec<TreeEntryWithLatest>, BabyError> {
110 share::validate_revision_non_empty(revision)?;
111 let path_buf = path.map(PathBuf::from);
112 if let Some(p) = &path_buf {
113 share::validate_local_no_escape(p)?;
114 }
115 let cmd_path = match &path_buf {
116 Some(p) => share::to_string_lossy_owned(p),
117 None => ".".to_string(),
118 };
119 let filter = path_buf
120 .as_deref()
121 .and_then(|p| p.to_str())
122 .filter(|s| !s.is_empty() && *s != ".");
123 let mut cmd = self
124 .spawn_tree_cmd(|dir, env| {
125 let args: Vec<String> = [
126 "-c",
127 "core.quotepath=off",
128 "ls-tree",
129 "-r",
130 "-t",
131 revision,
132 "--",
133 &cmd_path,
134 ]
135 .into_iter()
136 .map(String::from)
137 .collect();
138 Ok(share::git_cmd(helper::CALLER, dir, env, None, args))
139 })
140 .await?;
141 let output = cmd.run().await.map_err(helper::classify_cmd_error)?;
142 if !output.status.success() {
143 return Err(helper::classify_stderr(&String::from_utf8_lossy(
144 &output.stderr,
145 )));
146 }
147 let entries = helper::parse_ls_tree_entries(&output.stdout)?;
148 let full = |entry: &TreeEntry| -> String {
149 let mut s = entry.name.clone();
150 if entry.mode == TreeEntryMode::Tree {
151 s.push('/');
152 }
153 s
154 };
155 let path_strs: Vec<String> = entries.iter().map(full).collect();
156 let mut by_path: HashMap<String, CommitInfo> = self
157 .latest_commits_for_paths(revision, &path_strs, filter)
158 .await?;
159 let mut out = Vec::with_capacity(entries.len());
160 for (entry, path_str) in entries.into_iter().zip(path_strs) {
161 let latest = by_path.remove(&path_str).ok_or_else(|| {
162 BabyError::Custom(format!("missing latest commit for `{path_str}`"))
163 })?;
164 out.push(TreeEntryWithLatest { entry, latest });
165 }
166 Ok(out)
167 }
168
169 pub async fn list_dir(
170 &self,
171 revision: &str,
172 path: Option<&str>,
173 ) -> Result<Vec<TreeEntry>, BabyError> {
174 share::validate_revision_non_empty(revision)?;
175 let clean = path.unwrap_or("").trim().trim_start_matches("./");
176 if clean == ".." || clean.contains('\\') || clean.contains('\0') {
177 return Err(BabyError::PathEscapesRepository);
178 }
179 let treeish = format!("{}:{}", revision, clean);
180 let mut cmd = self
181 .spawn_tree_cmd(|dir, env| {
182 let args: Vec<String> = ["-c", "core.quotepath=off", "ls-tree", &treeish]
183 .into_iter()
184 .map(String::from)
185 .collect();
186 Ok(share::git_cmd(helper::CALLER, dir, env, None, args))
187 })
188 .await?;
189 let output = cmd.run().await.map_err(helper::classify_cmd_error)?;
190 if !output.status.success() {
191 return Err(helper::classify_stderr(&String::from_utf8_lossy(
192 &output.stderr,
193 )));
194 }
195 helper::parse_ls_tree_entries(&output.stdout)
196 }
197
198 pub async fn tree_dir_entries_with_latest(
199 &self,
200 revision: &str,
201 path: Option<&str>,
202 ) -> Result<Vec<TreeEntryWithLatest>, BabyError> {
203 share::validate_revision_non_empty(revision)?;
204 let clean = path
205 .unwrap_or("")
206 .trim()
207 .trim_start_matches("./")
208 .to_string();
209 let entries = self.list_dir(revision, path).await?;
210 let filter = if clean.is_empty() {
211 None
212 } else {
213 Some(clean.as_str())
214 };
215 let full = |entry: &TreeEntry| -> String {
216 let mut s = if clean.is_empty() {
217 entry.name.clone()
218 } else {
219 format!("{}/{}", clean, entry.name)
220 };
221 if entry.mode == TreeEntryMode::Tree {
222 s.push('/');
223 }
224 s
225 };
226 let path_strs: Vec<String> = entries.iter().map(full).collect();
227 let mut by_path: HashMap<String, CommitInfo> = self
228 .latest_commits_for_paths(revision, &path_strs, filter)
229 .await?;
230 let mut out = Vec::with_capacity(entries.len());
231 for (entry, path_str) in entries.into_iter().zip(path_strs) {
232 let latest = by_path.remove(&path_str).ok_or_else(|| {
233 BabyError::Custom(format!("missing latest commit for `{path_str}`"))
234 })?;
235 out.push(TreeEntryWithLatest { entry, latest });
236 }
237 Ok(out)
238 }
239
240 async fn latest_commits_for_paths(
241 &self,
242 revision: &str,
243 path_strs: &[String],
244 filter: Option<&str>,
245 ) -> Result<HashMap<String, CommitInfo>, BabyError> {
246 if path_strs.is_empty() {
247 return Ok(HashMap::new());
248 }
249 let filter_owned = filter.map(String::from);
250 let mut cmd = self
251 .spawn_tree_cmd(|dir, env| {
252 Ok(helper::build_log_paths_cmd(
253 dir,
254 env,
255 revision,
256 filter_owned.as_deref(),
257 ))
258 })
259 .await?;
260 let output = cmd.run().await.map_err(helper::classify_cmd_error)?;
261 if !output.status.success() {
262 return Err(helper::classify_stderr(&String::from_utf8_lossy(
263 &output.stderr,
264 )));
265 }
266 let stream = helper::parse_log_stream(&output.stdout);
267 let mut file_targets: HashSet<String> = HashSet::with_capacity(path_strs.len());
268 let mut tree_targets: HashSet<String> = HashSet::with_capacity(path_strs.len());
269 for p in path_strs {
270 if p.ends_with('/') {
271 tree_targets.insert(p.clone());
272 } else {
273 file_targets.insert(p.clone());
274 }
275 }
276 let mut hits: HashMap<String, ObjectId> = HashMap::with_capacity(path_strs.len());
277 for (oid, name) in stream.iter() {
278 if file_targets.contains(name) {
279 hits.entry(name.clone()).or_insert(*oid);
280 }
281 let mut idx = 0;
282 while let Some(rel) = name[idx..].find('/') {
283 idx += rel + 1;
284 if tree_targets.contains(&name[..idx]) {
285 hits.entry(name[..idx].to_string()).or_insert(*oid);
286 }
287 }
288 }
289 for p in path_strs {
290 if hits.contains_key(p) {
291 continue;
292 }
293 if let Some(hex) = self.rev_list_first(revision, p).await
294 && let Ok(oid) = gix::ObjectId::from_hex(hex.as_bytes())
295 {
296 hits.insert(p.clone(), oid);
297 }
298 }
299 let mut by_path: HashMap<String, CommitInfo> = HashMap::with_capacity(hits.len());
300 let mut oids: Vec<ObjectId> = hits.values().copied().collect();
301 oids.sort();
302 oids.dedup();
303 let mut infos: HashMap<ObjectId, CommitInfo> = HashMap::with_capacity(oids.len());
304 for oid in &oids {
305 if let Some(info) = self.cached_commit(oid).await {
306 infos.insert(*oid, info);
307 }
308 }
309 let missing_oids: Vec<ObjectId> = oids
310 .iter()
311 .copied()
312 .filter(|o| !infos.contains_key(o))
313 .collect();
314 if !missing_oids.is_empty() {
315 let fetched = self.batch_get_commits(&missing_oids).await?;
316 for (oid, info) in missing_oids.into_iter().zip(fetched) {
317 if let Some(info) = info {
318 self.write_commit_cache(&info);
319 infos.insert(oid, info);
320 }
321 }
322 }
323 for (p, oid) in hits {
324 if let Some(info) = infos.get(&oid).cloned() {
325 by_path.insert(p, info);
326 }
327 }
328 Ok(by_path)
329 }
330
331 async fn rev_list_first(&self, revision: &str, path: &str) -> Option<String> {
332 let mut cmd = self
333 .spawn_tree_cmd(|dir, env| {
334 Ok(helper::build_rev_list_latest_cmd(
335 dir,
336 env,
337 revision,
338 Path::new(path),
339 ))
340 })
341 .await
342 .ok()?;
343 let output = cmd.run().await.ok()?;
344 if !output.status.success() {
345 return None;
346 }
347 std::str::from_utf8(&output.stdout)
348 .ok()?
349 .split_whitespace()
350 .next()
351 .map(String::from)
352 }
353
354 async fn cached_commit(&self, oid: &ObjectId) -> Option<CommitInfo> {
355 let pool = crate::cache::try_global_cache();
356 let pool = pool.as_ref()?;
357 let key = crate::cache::object_key(crate::cache::NS_COMMIT, oid);
358 let v = pool.store().get(&key).await.ok().flatten()?;
359 serde_json::from_slice(&v).ok()
360 }
361
362 fn write_commit_cache(&self, info: &CommitInfo) {
363 if let Some(pool) = crate::cache::try_global_cache()
364 && let Ok(v) = serde_json::to_vec(info)
365 {
366 let key = crate::cache::object_key(crate::cache::NS_COMMIT, &info.id);
367 pool.store().insert(key, v);
368 }
369 }
370
371 async fn batch_get_commits(
372 &self,
373 oids: &[ObjectId],
374 ) -> Result<Vec<Option<CommitInfo>>, BabyError> {
375 if oids.is_empty() {
376 return Ok(Vec::new());
377 }
378 let oids = oids.to_vec();
379 let mut cmd = self
380 .spawn_tree_cmd(|dir, env| {
381 Ok(share::git_cmd(
382 helper::CALLER,
383 dir,
384 env,
385 None,
386 vec![
387 "cat-file".to_string(),
388 "--batch".to_string(),
389 "--buffer".to_string(),
390 ],
391 ))
392 })
393 .await?;
394 let mut input = String::with_capacity(oids.len() * 41);
395 for o in &oids {
396 input.push_str(&o.to_string());
397 input.push('\n');
398 }
399 cmd.spawn().await.map_err(helper::classify_cmd_error)?;
400 cmd.feed(input.as_bytes())
401 .await
402 .map_err(helper::classify_cmd_error)?;
403 let output = cmd.run().await.map_err(helper::classify_cmd_error)?;
404 if !output.status.success() {
405 return Err(helper::classify_stderr(&String::from_utf8_lossy(
406 &output.stderr,
407 )));
408 }
409 Ok(parse_cat_file_batch(&output.stdout, &oids))
410 }
411
412 pub async fn diff_trees(
413 &self,
414 old_rev: &str,
415 new_rev: &str,
416 path: &Path,
417 opts: DiffTreesOptions,
418 ) -> Result<Vec<TreeDiff>, BabyError> {
419 share::validate_revision_non_empty(old_rev)?;
420 share::validate_revision_non_empty(new_rev)?;
421 share::validate_local_no_escape(path)?;
422 let mut cmd = self
423 .spawn_tree_cmd(|dir, env| {
424 Ok(helper::build_diff_trees_cmd(
425 dir, env, old_rev, new_rev, path, &opts,
426 ))
427 })
428 .await?;
429 let output = cmd.run().await.map_err(helper::classify_cmd_error)?;
430 if !output.status.success() {
431 return Err(helper::classify_stderr(&String::from_utf8_lossy(
432 &output.stderr,
433 )));
434 }
435 helper::parse_diff_tree_z_output(&output.stdout)
436 }
437
438 async fn spawn_tree_cmd<F>(&self, builder: F) -> Result<crate::command::cmd::Cmd, BabyError>
439 where
440 F: FnOnce(
441 &std::path::Path,
442 crate::command::env::Env,
443 ) -> Result<crate::command::cmd::Cmd, BabyError>,
444 {
445 let dir = self
446 .facade
447 .git_repo_dir()
448 .await
449 .map_err(share::facade_error)?;
450 let alternates = self
451 .facade
452 .git_alternate_object_directories()
453 .await
454 .map_err(share::facade_error)?;
455 let env = share::build_env_safe(&alternates);
456 builder(&dir, env)
457 }
458}
459
460fn set_commit_env(
461 mut env: crate::command::env::Env,
462 author: &Signature,
463 committer: &Signature,
464) -> crate::command::env::Env {
465 env.set("GIT_AUTHOR_NAME", author.name.clone());
466 env.set("GIT_AUTHOR_EMAIL", author.email.clone());
467 env.set("GIT_AUTHOR_DATE", format_iso8601(author.time));
468 env.set("GIT_COMMITTER_NAME", committer.name.clone());
469 env.set("GIT_COMMITTER_EMAIL", committer.email.clone());
470 env.set("GIT_COMMITTER_DATE", format_iso8601(committer.time));
471 env
472}
473
474fn format_iso8601(t: time::OffsetDateTime) -> String {
475 let (y, mo, d) = t.to_calendar_date();
476 let (h, mi, s) = (t.hour(), t.minute(), t.second());
477 let off_secs = t.offset().whole_seconds();
478 let sign = if off_secs >= 0 { '+' } else { '-' };
479 let abs = off_secs.unsigned_abs();
480 let oh = abs / 3600;
481 let om = (abs % 3600) / 60;
482 format!(
483 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}",
484 y,
485 u8::from(mo),
486 d,
487 h,
488 mi,
489 s,
490 sign,
491 oh,
492 om
493 )
494}
495
496fn parse_cat_file_batch(stdout: &[u8], oids: &[ObjectId]) -> Vec<Option<CommitInfo>> {
497 let mut out: Vec<Option<CommitInfo>> = vec![None; oids.len()];
498 let mut idx_by_hex: std::collections::HashMap<String, usize> =
499 std::collections::HashMap::with_capacity(oids.len());
500 for (i, o) in oids.iter().enumerate() {
501 idx_by_hex.insert(o.to_string(), i);
502 }
503 let mut pos = 0;
504 while pos < stdout.len() {
505 let header_end = match stdout[pos..].iter().position(|&b| b == b'\n') {
506 Some(p) => pos + p,
507 None => break,
508 };
509 let header = std::str::from_utf8(&stdout[pos..header_end]).unwrap_or("");
510 let mut parts = header.split(' ');
511 let oid_hex = parts.next().unwrap_or("");
512 let kind = parts.next().unwrap_or("");
513 let size_str = parts.next().unwrap_or("");
514 let size: usize = match size_str.parse() {
515 Ok(n) => n,
516 Err(_) => break,
517 };
518 pos = header_end + 1;
519 let body_end = pos + size;
520 if body_end + 1 > stdout.len() {
521 break;
522 }
523 let body = &stdout[pos..body_end];
524 pos = body_end + 1;
525 if kind == "commit"
526 && let Some(slot) = idx_by_hex.get(oid_hex)
527 && let Ok(oid) = gix::ObjectId::from_hex(oid_hex.as_bytes())
528 && let Ok(info) = crate::commit::helper::parse_commit_porcelain(oid, body)
529 {
530 out[*slot] = Some(info);
531 }
532 }
533 out
534}
Added lib/gitbaby/src/error.rs lib/gitbaby/src/error.rs +285 −0 View lib/gitbaby/src/error.rs
@@ -0,0 +1,285 @@
1use std::fmt;
2use std::path::PathBuf;
3
4use crate::command::error::CmdError;
5
6#[derive(Debug)]
7pub enum BabyError {
8 Custom(String),
9 Command(CmdError),
10 Io {
11 path: PathBuf,
12 source: std::io::Error,
13 },
14 Spawn {
15 prog: String,
16 source: std::io::Error,
17 stderr: String,
18 },
19 InvalidRange,
20 PathEscapesRepository,
21 RevisionEmpty,
22 PathNotFound {
23 revision: String,
24 path: PathBuf,
25 },
26 OutOfRange {
27 actual_lines: u64,
28 revision: String,
29 path: PathBuf,
30 },
31 IgnoreRevsNotBlob {
32 revision: String,
33 },
34 Parse {
35 line_number: u64,
36 message: String,
37 },
38 RevisionNotFound(String),
39 RevisionInvalid(String),
40 NotATree {
41 revision: String,
42 path: PathBuf,
43 },
44 CommitParse {
45 line_number: u64,
46 message: String,
47 },
48 InvalidTimestamp(String),
49 BranchNotFound(String),
50 BranchAlreadyExists(String),
51 RefNotFound(String),
52 TagNotFound(String),
53 TagAlreadyExists(String),
54 InvalidRefName(String),
55 InvalidBranchName(String),
56 InvalidTagName(String),
57 RefUpdateRejected(String),
58 AmbiguousRef {
59 query: String,
60 candidates: Vec<String>,
61 },
62 TagVerifyFailed {
63 name: String,
64 reason: String,
65 },
66 MergeBaseNotFound(String),
67 ResolutionError {
68 reason: String,
69 },
70 BlobNotFound {
71 oid: String,
72 },
73 LFSPointerInvalid {
74 oid: String,
75 reason: String,
76 },
77 ConfigGetFailed {
78 key: String,
79 stderr: String,
80 },
81 ConfigSetFailed {
82 key: String,
83 stderr: String,
84 },
85 ArchiveFailed(String),
86 SubModuleParse {
87 line: u64,
88 reason: String,
89 },
90 RemoteNotFound(String),
91 RemoteAlreadyExists(String),
92 InvalidRemoteUrl(String),
93 FastImportFailed(String),
94 NotARepository(String),
95 GcFailed(String),
96 InvalidGitArg(String),
97 Unimplemented(&'static str),
98 PayloadTooLarge {
99 cap_bytes: u64,
100 actual_bytes: u64,
101 },
102 Cache {
103 op: &'static str,
104 source: foyer::Error,
105 },
106}
107
108impl fmt::Display for BabyError {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 match self {
111 Self::Custom(msg) => write!(f, "{}", msg),
112 Self::Command(err) => write!(f, "{}", err),
113 Self::Io { path, source } => {
114 write!(f, "io error at `{}`: {}", path.display(), source)
115 }
116 Self::Spawn {
117 prog,
118 source,
119 stderr,
120 } => write!(
121 f,
122 "`{}` failed: {} (stderr: {})",
123 prog,
124 source,
125 stderr.trim()
126 ),
127 Self::InvalidRange => {
128 write!(f, "invalid line range; expected 'A,B' with A <= B")
129 }
130 Self::PathEscapesRepository => write!(
131 f,
132 "blame path escapes repository (must be relative and contain no '..' components)"
133 ),
134 Self::RevisionEmpty => write!(f, "revision must not be empty"),
135 Self::PathNotFound { revision, path } => write!(
136 f,
137 "path `{}` does not exist at revision `{}`",
138 path.display(),
139 revision
140 ),
141 Self::OutOfRange {
142 actual_lines,
143 revision,
144 path,
145 } => write!(
146 f,
147 "path `{}` at revision `{}` has only {} line(s), below the requested range",
148 path.display(),
149 revision,
150 actual_lines
151 ),
152 Self::IgnoreRevsNotBlob { revision } => write!(
153 f,
154 "ignore-revs file at revision `{}` is not a blob",
155 revision
156 ),
157 Self::Parse {
158 line_number,
159 message,
160 } => write!(
161 f,
162 "malformed git blame output at line {}: {}",
163 line_number, message
164 ),
165 Self::RevisionNotFound(rev) => {
166 write!(f, "revision `{}` does not exist", rev)
167 }
168 Self::RevisionInvalid(rev) => {
169 write!(f, "revision `{}` is malformed or ambiguous", rev)
170 }
171 Self::NotATree { revision, path } => write!(
172 f,
173 "`{}` at revision `{}` is not a tree",
174 path.display(),
175 revision
176 ),
177 Self::CommitParse {
178 line_number,
179 message,
180 } => write!(
181 f,
182 "malformed commit porcelain at line {}: {}",
183 line_number, message
184 ),
185 Self::InvalidTimestamp(input) => {
186 write!(f, "invalid git timestamp: `{}`", input)
187 }
188 Self::BranchNotFound(name) => write!(f, "branch `{}` does not exist", name),
189 Self::BranchAlreadyExists(name) => write!(f, "branch `{}` already exists", name),
190 Self::RefNotFound(name) => write!(f, "ref `{}` does not exist", name),
191 Self::TagNotFound(name) => write!(f, "tag `{}` does not exist", name),
192 Self::TagAlreadyExists(name) => write!(f, "tag `{}` already exists", name),
193 Self::InvalidRefName(name) => write!(f, "invalid ref name `{}`", name),
194 Self::InvalidBranchName(name) => write!(f, "invalid branch name `{}`", name),
195 Self::InvalidTagName(name) => write!(f, "invalid tag name `{}`", name),
196 Self::RefUpdateRejected(reason) => {
197 write!(f, "ref update rejected by git: {}", reason.trim())
198 }
199 Self::AmbiguousRef { query, candidates } => write!(
200 f,
201 "ref `{}` is ambiguous; candidates: {}",
202 query,
203 candidates.join(", ")
204 ),
205 Self::TagVerifyFailed { name, reason } => {
206 write!(
207 f,
208 "tag `{}` signature verification failed: {}",
209 name, reason
210 )
211 }
212 Self::MergeBaseNotFound(spec) => {
213 write!(f, "no merge-base exists for `{}`", spec)
214 }
215 Self::ResolutionError { reason } => {
216 write!(f, "conflict resolution error: {}", reason)
217 }
218 Self::BlobNotFound { oid } => write!(f, "blob `{}` does not exist", oid),
219 Self::LFSPointerInvalid { oid, reason } => {
220 write!(f, "blob `{}` is not a valid LFS pointer: {}", oid, reason)
221 }
222 Self::ConfigGetFailed { key, stderr } => {
223 write!(f, "git config get for `{}` failed: {}", key, stderr.trim())
224 }
225 Self::ConfigSetFailed { key, stderr } => {
226 write!(f, "git config set for `{}` failed: {}", key, stderr.trim())
227 }
228 Self::ArchiveFailed(reason) => write!(f, "git archive failed: {}", reason.trim()),
229 Self::SubModuleParse { line, reason } => {
230 write!(f, "malformed .gitmodules at line {}: {}", line, reason)
231 }
232 Self::RemoteNotFound(name) => write!(f, "remote `{}` does not exist", name),
233 Self::RemoteAlreadyExists(name) => {
234 write!(f, "remote `{}` already exists", name)
235 }
236 Self::InvalidRemoteUrl(url) => write!(f, "invalid remote url `{}`", url),
237 Self::FastImportFailed(reason) => {
238 write!(f, "git fast-import failed: {}", reason.trim())
239 }
240 Self::NotARepository(path) => write!(f, "`{}` is not a git repository", path),
241 Self::GcFailed(reason) => write!(f, "git gc failed: {}", reason.trim()),
242 Self::InvalidGitArg(msg) => write!(f, "invalid git argument: {}", msg),
243 Self::Unimplemented(what) => write!(f, "not yet implemented: {}", what),
244 Self::PayloadTooLarge {
245 cap_bytes,
246 actual_bytes,
247 } => write!(
248 f,
249 "output exceeds cap: {} bytes > {} bytes cap",
250 actual_bytes, cap_bytes
251 ),
252 Self::Cache { op, source } => {
253 write!(f, "cache error during `{}`: {}", op, source)
254 }
255 }
256 }
257}
258
259impl std::error::Error for BabyError {
260 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
261 match self {
262 Self::Command(err) => Some(err),
263 Self::Io { source, .. } => Some(source),
264 Self::Spawn { source, .. } => Some(source),
265 Self::Cache { source, .. } => Some(source),
266 Self::AmbiguousRef { .. } | Self::TagVerifyFailed { .. } => None,
267 _ => None,
268 }
269 }
270}
271
272impl From<CmdError> for BabyError {
273 fn from(err: CmdError) -> Self {
274 Self::Command(err)
275 }
276}
277
278impl From<std::io::Error> for BabyError {
279 fn from(source: std::io::Error) -> Self {
280 Self::Io {
281 path: PathBuf::new(),
282 source,
283 }
284 }
285}
Added lib/gitbaby/src/lib.rs lib/gitbaby/src/lib.rs +42 −0 View lib/gitbaby/src/lib.rs
@@ -0,0 +1,42 @@
1use std::sync::Arc;
2
3use crate::repo::RepositoryFacade;
4
5pub mod archive;
6pub mod blame;
7pub mod blob;
8pub mod branch;
9pub mod cache;
10pub mod cleanup;
11pub mod command;
12pub mod commit;
13pub mod compare;
14pub mod config;
15pub mod conflict;
16pub mod diff;
17pub mod error;
18pub mod merge;
19pub mod refs;
20pub mod remote;
21pub mod repo;
22pub mod server;
23pub mod setup;
24pub mod share;
25pub mod submodule;
26pub mod tags;
27pub mod tree;
28
29#[derive(Clone)]
30pub struct GitBaby {
31 facade: Arc<dyn RepositoryFacade>,
32}
33
34impl GitBaby {
35 pub fn new(facade: Arc<dyn RepositoryFacade>) -> Self {
36 Self { facade }
37 }
38
39 pub fn facade(&self) -> &dyn RepositoryFacade {
40 &*self.facade
41 }
42}
Added lib/gitbaby/src/repo.rs lib/gitbaby/src/repo.rs +12 −0 View lib/gitbaby/src/repo.rs
@@ -0,0 +1,12 @@
1use std::path::PathBuf;
2
3use crate::error::BabyError;
4
5#[async_trait::async_trait]
6pub trait RepositoryFacade: Send + Sync + 'static {
7 async fn git_repo_dir(&self) -> Result<PathBuf, BabyError>;
8
9 async fn git_alternate_object_directories(&self) -> Result<Vec<PathBuf>, BabyError>;
10
11 async fn gix_repo(&self) -> Result<gix::Repository, BabyError>;
12}
Added lib/gitbaby/tests/archive.rs lib/gitbaby/tests/archive.rs +319 −0 View lib/gitbaby/tests/archive.rs
@@ -0,0 +1,319 @@
1use std::path::{Path, PathBuf};
2use std::process::Command;
3use std::sync::Arc;
4use std::sync::atomic::{AtomicU32, Ordering};
5
6use async_trait::async_trait;
7
8use gitbaby::GitBaby;
9use gitbaby::archive::{ArchiveFormat, BundleRequest, GetArchiveRequest};
10use gitbaby::error::BabyError;
11use gitbaby::repo::RepositoryFacade;
12
13static COUNTER: AtomicU32 = AtomicU32::new(0);
14
15struct TestFacade {
16 dir: PathBuf,
17}
18
19#[async_trait]
20impl RepositoryFacade for TestFacade {
21 async fn git_repo_dir(&self) -> Result<PathBuf, BabyError> {
22 Ok(self.dir.clone())
23 }
24
25 async fn git_alternate_object_directories(&self) -> Result<Vec<PathBuf>, BabyError> {
26 Ok(Vec::new())
27 }
28
29 async fn gix_repo(&self) -> Result<gix::Repository, BabyError> {
30 gix::open(&self.dir).map_err(|e| BabyError::Custom(e.to_string()))
31 }
32}
33
34fn temp_repo() -> PathBuf {
35 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
36 let dir = std::env::temp_dir().join(format!("gitbaby-archive-test-{}-{n}", std::process::id()));
37 let _ = std::fs::remove_dir_all(&dir);
38 std::fs::create_dir_all(&dir).expect("mkdir repo");
39 let out = Command::new("git")
40 .args(["init", "-q"])
41 .current_dir(&dir)
42 .output()
43 .expect("git init failed");
44 assert!(out.status.success(), "git init: {:?}", out);
45 dir
46}
47
48fn cleanup(repo: &Path) {
49 let _ = std::fs::remove_dir_all(repo);
50}
51
52fn git_commit(repo: &Path, msg: &str) {
53 let out = Command::new("git")
54 .args([
55 "-c",
56 "user.name=T",
57 "-c",
58 "user.email=t@e",
59 "commit",
60 "-q",
61 "-m",
62 msg,
63 ])
64 .current_dir(repo)
65 .output()
66 .expect("git commit failed");
67 assert!(out.status.success(), "git commit `{msg}`: {:?}", out);
68}
69
70fn write_file(repo: &Path, rel: &str, content: &str) {
71 let p = repo.join(rel);
72 std::fs::create_dir_all(p.parent().expect("parent")).expect("mkdir");
73 std::fs::write(p, content).expect("write");
74}
75
76fn index_commit(repo: &Path, msg: &str) {
77 let out = Command::new("git")
78 .args(["add", "-A"])
79 .current_dir(repo)
80 .output()
81 .expect("git add failed");
82 assert!(out.status.success(), "git add: {:?}", out);
83 git_commit(repo, msg);
84}
85
86async fn baby_of(repo: PathBuf) -> GitBaby {
87 GitBaby::new(Arc::new(TestFacade { dir: repo }))
88}
89
90fn archive_dir(repo: &Path) -> PathBuf {
91 repo.join("archive")
92}
93
94fn archive_files(repo: &Path) -> Vec<PathBuf> {
95 let mut files: Vec<PathBuf> = std::fs::read_dir(archive_dir(repo))
96 .expect("read archive dir")
97 .filter_map(|e| e.ok())
98 .map(|e| e.path())
99 .filter(|p| p.is_file())
100 .collect();
101 files.sort();
102 files
103}
104
105#[tokio::test]
106async fn get_archive_persists_and_hits_cache() {
107 let repo = temp_repo();
108 write_file(&repo, "a.txt", "one");
109 write_file(&repo, "b.txt", "bee");
110 index_commit(&repo, "first");
111
112 let baby = baby_of(repo.clone()).await;
113 let req = || GetArchiveRequest {
114 commit: "HEAD".to_string(),
115 format: ArchiveFormat::Tar,
116 prefix: None,
117 path: None,
118 exclude: Vec::new(),
119 };
120
121 let first_bytes = baby
122 .get_archive(req())
123 .await
124 .expect("get_archive")
125 .read_to_end()
126 .await
127 .expect("read archive body");
128
129 let files = archive_files(&repo);
130 assert_eq!(files.len(), 1, "one cached archive expected");
131 assert_eq!(
132 files[0].extension().and_then(|e| e.to_str()),
133 Some("tar"),
134 "cached file: {:?}",
135 files[0]
136 );
137 let on_disk = std::fs::read(&files[0]).expect("read cached file");
138 assert_eq!(on_disk, first_bytes, "cached bytes match streamed bytes");
139
140 let direct = Command::new("git")
141 .args(["archive", "--format=tar", "HEAD"])
142 .current_dir(&repo)
143 .output()
144 .expect("git archive direct");
145 assert!(direct.status.success(), "git archive direct: {:?}", direct);
146 assert_eq!(direct.stdout, first_bytes, "matches raw git archive output");
147
148 let second_bytes = baby
149 .get_archive(req())
150 .await
151 .expect("get_archive cached")
152 .read_to_end()
153 .await
154 .expect("read archive body");
155 assert_eq!(second_bytes, first_bytes, "cache hit returns same bytes");
156 assert_eq!(archive_files(&repo).len(), 1);
157
158 cleanup(&repo);
159}
160
161#[tokio::test]
162async fn get_archive_keys_differ_by_options() {
163 let repo = temp_repo();
164 write_file(&repo, "a.txt", "one");
165 index_commit(&repo, "first");
166
167 let baby = baby_of(repo.clone()).await;
168 let base = GetArchiveRequest {
169 commit: "HEAD".to_string(),
170 format: ArchiveFormat::Tar,
171 prefix: None,
172 path: None,
173 exclude: Vec::new(),
174 };
175
176 let plain = baby
177 .get_archive(base.clone())
178 .await
179 .expect("plain")
180 .read_to_end()
181 .await
182 .expect("plain body");
183
184 let prefixed = baby
185 .get_archive(GetArchiveRequest {
186 prefix: Some("p/".to_string()),
187 ..base.clone()
188 })
189 .await
190 .expect("prefixed")
191 .read_to_end()
192 .await
193 .expect("prefixed body");
194
195 let excluded = baby
196 .get_archive(GetArchiveRequest {
197 exclude: vec!["a.txt".to_string()],
198 ..base.clone()
199 })
200 .await
201 .expect("excluded")
202 .read_to_end()
203 .await
204 .expect("excluded body");
205
206 assert_ne!(plain, prefixed);
207 assert_ne!(plain, excluded);
208 assert_ne!(prefixed, excluded);
209
210 let files = archive_files(&repo);
211 assert_eq!(files.len(), 3, "three distinct keys cached");
212
213 let zipped = baby
214 .get_archive(GetArchiveRequest {
215 format: ArchiveFormat::Zip,
216 ..base
217 })
218 .await
219 .expect("zip")
220 .read_to_end()
221 .await
222 .expect("zip body");
223 let files = archive_files(&repo);
224 assert_eq!(files.len(), 4);
225 assert!(
226 files
227 .iter()
228 .any(|p| p.extension().and_then(|e| e.to_str()) == Some("zip")),
229 "zip file present: {:?}",
230 files
231 );
232 assert!(zipped.starts_with(b"PK"), "zip magic");
233 assert_ne!(zipped, plain);
234
235 cleanup(&repo);
236}
237
238#[tokio::test]
239async fn get_archive_new_commit_new_key() {
240 let repo = temp_repo();
241 write_file(&repo, "a.txt", "one");
242 index_commit(&repo, "first");
243
244 let baby = baby_of(repo.clone()).await;
245 let req = || GetArchiveRequest {
246 commit: "HEAD".to_string(),
247 format: ArchiveFormat::Tar,
248 prefix: None,
249 path: None,
250 exclude: Vec::new(),
251 };
252
253 let _ = baby
254 .get_archive(req())
255 .await
256 .expect("first revision")
257 .read_to_end()
258 .await
259 .expect("body");
260 assert_eq!(archive_files(&repo).len(), 1);
261
262 write_file(&repo, "a.txt", "two");
263 index_commit(&repo, "second");
264
265 let _ = baby
266 .get_archive(req())
267 .await
268 .expect("second revision")
269 .read_to_end()
270 .await
271 .expect("body");
272 let files = archive_files(&repo);
273 assert_eq!(files.len(), 2, "new commit produces new cached key");
274
275 cleanup(&repo);
276}
277
278#[tokio::test]
279async fn create_bundle_persists_and_hits_cache() {
280 let repo = temp_repo();
281 write_file(&repo, "a.txt", "one");
282 index_commit(&repo, "first");
283
284 let baby = baby_of(repo.clone()).await;
285 let req = || BundleRequest {
286 refs: vec!["HEAD".to_string()],
287 };
288
289 let first = baby
290 .create_bundle(req())
291 .await
292 .expect("create_bundle")
293 .read_to_end()
294 .await
295 .expect("bundle body");
296
297 let files = archive_files(&repo);
298 assert_eq!(files.len(), 1, "one cached bundle expected");
299 assert_eq!(
300 files[0].extension().and_then(|e| e.to_str()),
301 Some("bundle"),
302 "cached file: {:?}",
303 files[0]
304 );
305 let on_disk = std::fs::read(&files[0]).expect("read cached file");
306 assert_eq!(on_disk, first, "cached bundle bytes match streamed bytes");
307
308 let second = baby
309 .create_bundle(req())
310 .await
311 .expect("create_bundle cached")
312 .read_to_end()
313 .await
314 .expect("bundle body");
315 assert_eq!(second, first, "cache hit returns same bundle bytes");
316 assert_eq!(archive_files(&repo).len(), 1);
317
318 cleanup(&repo);
319}
Added lib/gitbaby/tests/cmd_run.rs lib/gitbaby/tests/cmd_run.rs +140 −0 View lib/gitbaby/tests/cmd_run.rs
@@ -0,0 +1,140 @@
1use gitbaby::command::cmd::Cmd;
2use std::path::PathBuf;
3
4macro_rules! unwrap_or_panic {
5 ($expr:expr, $msg:literal) => {
6 match $expr {
7 Ok(v) => v,
8 Err(e) => panic!("{}: {:?}", $msg, e),
9 }
10 };
11 ($expr:expr, $fmt:literal, $($arg:tt)*) => {
12 match $expr {
13 Ok(v) => v,
14 Err(e) => panic!("{}: {:?}", format_args!($fmt, $($arg)*), e),
15 }
16 };
17}
18
19#[tokio::test]
20async fn run_echo_and_capture_output() {
21 let mut cmd = Cmd::new(
22 "test:echo",
23 "sh",
24 vec!["-c".to_string(), "echo hello".to_string()],
25 vec![],
26 PathBuf::from("."),
27 vec![],
28 None,
29 );
30 let out = unwrap_or_panic!(cmd.run().await, "run failed");
31 assert!(out.status.success(), "exit code should be 0");
32 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello");
33}
34
35#[tokio::test]
36async fn run_with_non_zero_exit_returns_output() {
37 let mut cmd = Cmd::new(
38 "test:false",
39 "sh",
40 vec!["-c".to_string(), "echo oops 1>&2; exit 7".to_string()],
41 vec![],
42 PathBuf::from("."),
43 vec![],
44 None,
45 );
46 let res = cmd.run().await;
47 match res {
48 Ok(_) => panic!("run() with non-zero exit should fail"),
49 Err(gitbaby::command::error::CmdError::NonZeroExit { code, stderr, .. }) => {
50 assert_eq!(code, 7);
51 assert_eq!(stderr.trim(), "oops");
52 }
53 Err(e) => panic!("unexpected error: {:?}", e),
54 }
55}
56
57#[tokio::test]
58async fn run_soft_keeps_non_zero_exit_in_output() {
59 let mut cmd = Cmd::new(
60 "test:false-soft",
61 "sh",
62 vec!["-c".to_string(), "echo oops 1>&2; exit 7".to_string()],
63 vec![],
64 PathBuf::from("."),
65 vec![],
66 None,
67 );
68 let out = unwrap_or_panic!(cmd.run_soft().await, "run_soft failed");
69 assert_eq!(out.status.code(), Some(7));
70 assert_eq!(String::from_utf8_lossy(&out.stderr).trim(), "oops");
71}
72
73#[tokio::test]
74async fn feed_writes_to_stdin() {
75 let mut cmd = Cmd::new(
76 "test:cat",
77 "cat",
78 vec![],
79 vec![],
80 PathBuf::from("."),
81 vec![],
82 None,
83 );
84 unwrap_or_panic!(cmd.spawn().await, "spawn failed");
85 unwrap_or_panic!(cmd.feed(b"via-stdin").await, "feed failed");
86 let out = unwrap_or_panic!(cmd.run().await, "run failed");
87 assert_eq!(String::from_utf8_lossy(&out.stdout), "via-stdin");
88}
89
90#[tokio::test]
91async fn env_vars_are_applied() {
92 let mut cmd = Cmd::new(
93 "test:env",
94 "sh",
95 vec!["-c".to_string(), "printf %s \"$GREETING\"".to_string()],
96 vec![],
97 PathBuf::from("."),
98 vec![("GREETING".to_string(), "hi".to_string())],
99 None,
100 );
101 let out = unwrap_or_panic!(cmd.run().await, "run failed");
102 assert!(out.status.success());
103 assert_eq!(String::from_utf8_lossy(&out.stdout), "hi");
104}
105
106#[tokio::test]
107async fn config_args_prepend_to_command_line() {
108 let mut cmd = Cmd::new(
109 "test:cfg",
110 "echo",
111 vec!["world".to_string()],
112 vec!["ignored-no-flag-echo".to_string()],
113 PathBuf::from("."),
114 vec![],
115 None,
116 );
117 let rendered = format!("{cmd}");
118 assert_eq!(rendered, "echo ignored-no-flag-echo world");
119
120 let out = unwrap_or_panic!(cmd.run().await, "run failed");
121 assert_eq!(
122 String::from_utf8_lossy(&out.stdout).trim(),
123 "ignored-no-flag-echo world"
124 );
125}
126
127#[tokio::test]
128async fn timeout_kills_long_running_process() {
129 let mut cmd = Cmd::new(
130 "test:sleep",
131 "sleep",
132 vec!["5".to_string()],
133 vec![],
134 PathBuf::from("."),
135 vec![],
136 Some(1),
137 );
138 let res = cmd.run().await;
139 assert!(res.is_err(), "timeout should produce an error");
140}
Added lib/gitbaby/tests/context.rs lib/gitbaby/tests/context.rs +160 −0 View lib/gitbaby/tests/context.rs
@@ -0,0 +1,160 @@
1use gitbaby::command::context::GitPipeContext;
2
3struct MinimalCtx;
4
5impl GitPipeContext for MinimalCtx {
6 fn cancel_pipe(&mut self) {}
7}
8
9struct StatefulCtx {
10 cancelled: bool,
11 current: Option<usize>,
12 finished: Vec<usize>,
13 progress: Vec<(usize, u64)>,
14}
15
16impl GitPipeContext for StatefulCtx {
17 fn cancel_pipe(&mut self) {
18 self.cancelled = true;
19 }
20 fn current_index(&self) -> Option<usize> {
21 self.current
22 }
23 fn on_command_finished(&mut self, index: usize) {
24 self.finished.push(index);
25 }
26 fn on_progress(&mut self, index: usize, bytes: u64) {
27 self.progress.push((index, bytes));
28 }
29}
30
31#[test]
32fn default_current_index_is_none() {
33 let ctx = MinimalCtx;
34 assert_eq!(ctx.current_index(), None);
35}
36
37#[test]
38fn default_hooks_are_noops() {
39 let mut ctx = MinimalCtx;
40 ctx.on_command_finished(0);
41 ctx.on_command_finished(7);
42 ctx.on_progress(2, 1024);
43 ctx.on_progress(5, 9999);
44 assert_eq!(ctx.current_index(), None);
45}
46
47#[test]
48fn cancel_pipe_marks_cancelled_state() {
49 let mut ctx = StatefulCtx {
50 cancelled: false,
51 current: Some(0),
52 finished: vec![],
53 progress: vec![],
54 };
55 assert!(!ctx.cancelled);
56 ctx.cancel_pipe();
57 assert!(ctx.cancelled);
58}
59
60#[test]
61fn on_command_finished_records_indices_in_order() {
62 let mut ctx = StatefulCtx {
63 cancelled: false,
64 current: None,
65 finished: vec![],
66 progress: vec![],
67 };
68 for i in [0_usize, 1, 2, 3] {
69 ctx.on_command_finished(i);
70 }
71 assert_eq!(ctx.finished, vec![0, 1, 2, 3]);
72}
73
74#[test]
75fn on_progress_records_index_and_bytes_pairs() {
76 let mut ctx = StatefulCtx {
77 cancelled: false,
78 current: None,
79 finished: vec![],
80 progress: vec![],
81 };
82 ctx.on_progress(0, 0);
83 ctx.on_progress(0, 1024);
84 ctx.on_progress(1, 2048);
85 assert_eq!(ctx.progress, vec![(0, 0), (0, 1024), (1, 2048)]);
86}
87
88#[test]
89fn current_index_returns_impl_specific_value() {
90 let ctx = StatefulCtx {
91 cancelled: false,
92 current: Some(3),
93 finished: vec![],
94 progress: vec![],
95 };
96 assert_eq!(ctx.current_index(), Some(3));
97}
98
99#[test]
100fn current_index_can_be_none_even_when_active() {
101 let ctx = StatefulCtx {
102 cancelled: false,
103 current: None,
104 finished: vec![],
105 progress: vec![],
106 };
107 assert_eq!(ctx.current_index(), None);
108}
109
110#[test]
111fn trait_object_dispatch_invokes_impl() {
112 let mut ctx = StatefulCtx {
113 cancelled: false,
114 current: Some(2),
115 finished: vec![],
116 progress: vec![],
117 };
118 {
119 let trait_obj: &mut dyn GitPipeContext = &mut ctx;
120 trait_obj.cancel_pipe();
121 trait_obj.on_command_finished(1);
122 trait_obj.on_progress(1, 42);
123 assert_eq!(trait_obj.current_index(), Some(2));
124 }
125
126 assert!(ctx.cancelled);
127 assert_eq!(ctx.finished, vec![1]);
128 assert_eq!(ctx.progress, vec![(1, 42)]);
129}
130
131#[test]
132fn multiple_impls_are_independent_via_trait_objects() {
133 let mut a = StatefulCtx {
134 cancelled: false,
135 current: Some(0),
136 finished: vec![],
137 progress: vec![],
138 };
139 let mut b = StatefulCtx {
140 cancelled: false,
141 current: Some(1),
142 finished: vec![],
143 progress: vec![],
144 };
145
146 {
147 let trait_obj: &mut dyn GitPipeContext = &mut a;
148 trait_obj.cancel_pipe();
149 }
150 {
151 let trait_obj: &mut dyn GitPipeContext = &mut b;
152 trait_obj.on_command_finished(7);
153 }
154
155 assert!(a.cancelled);
156 assert!(!b.cancelled);
157 assert_eq!(b.finished, vec![7]);
158 assert_eq!(a.current_index(), Some(0));
159 assert_eq!(b.current_index(), Some(1));
160}
Added lib/gitbaby/tests/env.rs lib/gitbaby/tests/env.rs +137 −0 View lib/gitbaby/tests/env.rs
@@ -0,0 +1,137 @@
1use gitbaby::command::env::Env;
2use std::sync::{Mutex, MutexGuard, PoisonError};
3
4static ENV_LOCK: Mutex<()> = Mutex::new(());
5
6fn lock_or_recover() -> MutexGuard<'static, ()> {
7 match ENV_LOCK.lock() {
8 Ok(g) => g,
9 Err(PoisonError { .. }) => panic!("env lock poisoned"),
10 }
11}
12
13fn unique_key(suffix: &str) -> String {
14 format!("GITBABY_TEST_ENV_{suffix}")
15}
16
17unsafe fn set_var(key: &str, val: &str) {
18 unsafe { std::env::set_var(key, val) }
19}
20
21unsafe fn remove_var(key: &str) {
22 unsafe { std::env::remove_var(key) }
23}
24
25#[test]
26fn from_current_env_includes_system_var() {
27 let _guard = lock_or_recover();
28 let key = unique_key("FROM_CUR");
29 unsafe { set_var(&key, "from-system") };
30
31 let env = Env::from_current_env();
32 assert_eq!(env.get(&key), Some("from-system"));
33
34 unsafe { remove_var(&key) };
35}
36
37#[test]
38fn from_current_env_contains_path_like_var() {
39 let env = Env::from_current_env();
40 assert!(env.get("PATH").is_some() || env.get("Path").is_some() || !env.is_empty());
41}
42
43#[test]
44fn set_overrides_system_var_after_from_current_env() {
45 let _guard = lock_or_recover();
46 let key = unique_key("OVERRIDE");
47 unsafe { set_var(&key, "system-value") };
48
49 let mut env = Env::from_current_env();
50 env.set(&key, "user-value");
51 assert_eq!(env.get(&key), Some("user-value"));
52
53 unsafe { remove_var(&key) };
54}
55
56#[test]
57fn with_current_env_overrides_user_added_var() {
58 let _guard = lock_or_recover();
59 let key = unique_key("WC_OVERRIDE");
60 unsafe { set_var(&key, "system-wins") };
61
62 let env = Env::new().with(&key, "user-loses").with_current_env();
63 assert_eq!(env.get(&key), Some("system-wins"));
64
65 unsafe { remove_var(&key) };
66}
67
68#[test]
69fn overlay_current_env_preserves_user_added_var() {
70 let _guard = lock_or_recover();
71 let key = unique_key("OVERLAY");
72 unsafe { set_var(&key, "system-value") };
73
74 let env = Env::new().with(&key, "user-keeps").overlay_current_env();
75 assert_eq!(env.get(&key), Some("user-keeps"));
76
77 unsafe { remove_var(&key) };
78}
79
80#[test]
81fn overlay_current_env_fills_missing_keys() {
82 let _guard = lock_or_recover();
83 let mut iter = std::env::vars_os();
84 let known = match iter.next() {
85 Some(v) => v,
86 None => panic!("process should have at least one env var"),
87 };
88 let known_key = known.0.to_string_lossy().into_owned();
89
90 let env = Env::new().overlay_current_env();
91 assert!(env.get(&known_key).is_some());
92}
93
94#[test]
95fn unset_removes_system_var_when_loaded() {
96 let _guard = lock_or_recover();
97 let key = unique_key("UNSET");
98 unsafe { set_var(&key, "temp") };
99
100 let mut env = Env::from_current_env();
101 assert_eq!(env.get(&key), Some("temp"));
102
103 env.unset(&key);
104 assert_eq!(env.get(&key), None);
105
106 unsafe { remove_var(&key) };
107}
108
109#[test]
110fn from_current_env_is_cloneable() {
111 let _guard = lock_or_recover();
112 let key = unique_key("CLONE");
113 unsafe { set_var(&key, "v") };
114
115 let env = Env::from_current_env();
116 let copy = env.clone();
117 assert_eq!(env.get(&key), Some("v"));
118 assert_eq!(copy.get(&key), Some("v"));
119
120 unsafe { remove_var(&key) };
121}
122
123#[test]
124fn set_after_from_current_env_does_not_grow_pairs_when_key_known() {
125 let _guard = lock_or_recover();
126 let key = unique_key("NOGROW");
127 unsafe { set_var(&key, "system") };
128
129 let mut env = Env::from_current_env();
130 let before = env.len();
131 env.set(&key, "user");
132 let after = env.len();
133 assert_eq!(before, after, "set 应替换而非新增");
134 assert_eq!(env.get(&key), Some("user"));
135
136 unsafe { remove_var(&key) };
137}
Added lib/gitbaby/tests/pipe.rs lib/gitbaby/tests/pipe.rs +169 −0 View lib/gitbaby/tests/pipe.rs
@@ -0,0 +1,169 @@
1use gitbaby::command::cmd::Cmd;
2use gitbaby::command::context::GitPipeContext;
3use gitbaby::command::pipe::Pipe;
4use std::path::PathBuf;
5use std::time::Duration;
6
7fn make_cmd(caller: &str, prog: &str, arg: &str) -> Cmd {
8 Cmd::new(
9 caller,
10 prog,
11 vec![arg.to_string()],
12 vec![],
13 PathBuf::from("."),
14 vec![],
15 None,
16 )
17}
18
19fn must_some<T>(opt: Option<T>, msg: &str) -> T {
20 match opt {
21 Some(v) => v,
22 None => panic!("{msg}"),
23 }
24}
25
26#[test]
27fn pipe_builder_sets_initial_fields() {
28 let pipe = Pipe::new("demo", PathBuf::from("/tmp/work"));
29 assert_eq!(pipe.name, "demo");
30 assert_eq!(pipe.dir, PathBuf::from("/tmp/work"));
31 assert_eq!(pipe.len(), 0);
32 assert!(pipe.is_empty());
33 assert!(!pipe.is_cancelled());
34 assert_eq!(pipe.timeout, None);
35 assert!(pipe.first().is_none());
36 assert!(pipe.last().is_none());
37}
38
39#[test]
40fn pipe_push_grows_cmds_and_chains() {
41 let pipe = Pipe::new("chain", PathBuf::from("."))
42 .push(make_cmd("c1", "echo", "a"))
43 .push(make_cmd("c2", "echo", "b"));
44
45 assert_eq!(pipe.len(), 2);
46 assert!(!pipe.is_empty());
47 assert_eq!(
48 must_some(pipe.first(), "first must be Some").caller_info,
49 "c1"
50 );
51 assert_eq!(
52 must_some(pipe.last(), "last must be Some").caller_info,
53 "c2"
54 );
55}
56
57#[test]
58fn pipe_extend_accepts_iterator() {
59 let cmds = vec![
60 make_cmd("a", "echo", "1"),
61 make_cmd("b", "echo", "2"),
62 make_cmd("c", "echo", "3"),
63 ];
64 let pipe = Pipe::new("ext", PathBuf::from(".")).extend(cmds);
65 assert_eq!(pipe.len(), 3);
66 assert_eq!(must_some(pipe.last(), "last must be Some").caller_info, "c");
67}
68
69#[test]
70fn pipe_with_dir_overrides_directory() {
71 let pipe = Pipe::new("d", PathBuf::from("/old")).with_dir(PathBuf::from("/new"));
72 assert_eq!(pipe.dir, PathBuf::from("/new"));
73}
74
75#[test]
76fn pipe_with_timeout_sets_value() {
77 let pipe = Pipe::new("t", PathBuf::from(".")).with_timeout(5_000);
78 assert_eq!(pipe.timeout, Some(5_000));
79}
80
81#[test]
82fn pipe_display_shows_name_and_joined_commands() {
83 let pipe = Pipe::new("pipe-name", PathBuf::from("."))
84 .push(make_cmd("c1", "git", "log"))
85 .push(make_cmd("c2", "git", "show"));
86 let rendered = format!("{pipe}");
87 assert_eq!(rendered, "pipe-name: git log | git show");
88}
89
90#[test]
91fn pipe_display_when_empty() {
92 let pipe = Pipe::new("empty-pipe", PathBuf::from("."));
93 assert_eq!(format!("{pipe}"), "empty-pipe:<empty>");
94}
95
96#[test]
97fn pipe_debug_includes_field_summary() {
98 let pipe = Pipe::new("dbg", PathBuf::from("."))
99 .with_timeout(42)
100 .push(make_cmd("c", "echo", "x"));
101 let rendered = format!("{pipe:?}");
102 assert!(rendered.contains("Pipe"));
103 assert!(rendered.contains("dbg"));
104 assert!(rendered.contains("42"));
105 assert!(rendered.contains("cancelled: false"));
106}
107
108#[test]
109fn pipe_current_index_unset_when_empty() {
110 let pipe = Pipe::new("e", PathBuf::from("."));
111 assert_eq!(pipe.current_index(), None);
112}
113
114#[test]
115fn pipe_current_index_after_push() {
116 let pipe = Pipe::new("e", PathBuf::from("."))
117 .push(make_cmd("c1", "echo", "a"))
118 .push(make_cmd("c2", "echo", "b"));
119 assert_eq!(pipe.current_index(), Some(1));
120}
121
122#[test]
123fn pipe_cancel_sets_flag_and_drops_index() {
124 let mut pipe = Pipe::new("c", PathBuf::from("."))
125 .push(make_cmd("c1", "echo", "a"))
126 .push(make_cmd("c2", "echo", "b"));
127 assert!(!pipe.is_cancelled());
128 assert_eq!(pipe.current_index(), Some(1));
129
130 pipe.cancel_pipe();
131 assert!(pipe.is_cancelled());
132 assert_eq!(pipe.current_index(), None);
133}
134
135#[tokio::test]
136async fn pipe_cancel_kills_running_children() {
137 let mut pipe = Pipe::new("kill", PathBuf::from(".")).push(Cmd::new(
138 "sleeper",
139 "sleep",
140 vec!["30".to_string()],
141 vec![],
142 PathBuf::from("."),
143 vec![],
144 None,
145 ));
146 if let Err(e) = pipe.cmds[0].spawn().await {
147 panic!("spawn failed: {:?}", e);
148 }
149
150 pipe.cancel_pipe();
151 assert!(pipe.is_cancelled());
152 assert!(!pipe.spawn_allowed());
153
154 tokio::time::sleep(Duration::from_millis(200)).await;
155 // The child handle is handed to the reaper task; kill must be rejected
156 // for new spawns and the stale handle must no longer be owned by the Cmd.
157 assert!(
158 pipe.cmds[0].cmd.is_none(),
159 "cancel_pipe should take over the child handle"
160 );
161}
162
163#[tokio::test]
164async fn pipe_cancel_is_safe_when_no_children_running() {
165 let mut pipe = Pipe::new("safe", PathBuf::from(".")).push(make_cmd("c", "echo", "x"));
166 pipe.cancel_pipe();
167 assert!(pipe.is_cancelled());
168 assert_eq!(pipe.current_index(), None);
169}
Added lib/gitbaby/tests/server.rs lib/gitbaby/tests/server.rs +392 −0 View lib/gitbaby/tests/server.rs
@@ -0,0 +1,392 @@
1use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
2use std::time::Duration;
3
4use tokio::io::AsyncReadExt;
5
6use gitbaby::server::{
7 AuthContext, AuthScheme, ChildPackReader, ChildPackWriter, EndpointKind, IngestReport,
8 PackStats, Principal, RefUpdateBatch, RefUpdateEntry, ServerBackend, ServerError,
9 ServerRequest, StreamGuard, StreamOutcome, buf_empty_reader, empty_reader,
10};
11
12static ENV_LOCK: Mutex<()> = Mutex::new(());
13
14fn lock_or_recover() -> MutexGuard<'static, ()> {
15 match ENV_LOCK.lock() {
16 Ok(g) => g,
17 Err(PoisonError { .. }) => panic!("env lock poisoned"),
18 }
19}
20
21#[derive(Debug)]
22struct PlaceholderBackend;
23
24#[async_trait::async_trait]
25impl ServerBackend for PlaceholderBackend {
26 async fn authorize(&self, _req: &ServerRequest) -> Result<AuthContext, ServerError> {
27 Err(ServerError::Unimplemented(
28 "server::ServerBackend::authorize",
29 ))
30 }
31
32 async fn list_refs_stream(
33 &self,
34 _req: &ServerRequest,
35 ) -> Result<Box<dyn tokio::io::AsyncRead + Unpin + Send + Sync>, ServerError> {
36 Err(ServerError::Unimplemented(
37 "server::ServerBackend::list_refs_stream",
38 ))
39 }
40
41 async fn produce_pack_stream(
42 &self,
43 _req: &ServerRequest,
44 _wants: &[String],
45 ) -> Result<Box<dyn tokio::io::AsyncRead + Unpin + Send + Sync>, ServerError> {
46 Err(ServerError::Unimplemented(
47 "server::ServerBackend::produce_pack_stream",
48 ))
49 }
50
51 async fn ingest_pack_stream(
52 &self,
53 _req: &ServerRequest,
54 _batch: RefUpdateBatch,
55 ) -> Result<IngestReport, ServerError> {
56 Err(ServerError::Unimplemented(
57 "server::ServerBackend::ingest_pack_stream",
58 ))
59 }
60
61 async fn lfs_get_stream(
62 &self,
63 _req: &ServerRequest,
64 _oid: &str,
65 ) -> Result<Box<dyn tokio::io::AsyncRead + Unpin + Send + Sync>, ServerError> {
66 Err(ServerError::Unimplemented(
67 "server::ServerBackend::lfs_get_stream",
68 ))
69 }
70
71 async fn lfs_put_stream(
72 &self,
73 _req: &ServerRequest,
74 _oid: &str,
75 _declared_size: u64,
76 ) -> Result<IngestReport, ServerError> {
77 Err(ServerError::Unimplemented(
78 "server::ServerBackend::lfs_put_stream",
79 ))
80 }
81}
82
83fn make_request(method: &str, path: &str, endpoint: EndpointKind) -> ServerRequest {
84 ServerRequest::new(method, path, endpoint, "test-service", empty_reader())
85}
86
87#[test]
88fn auth_scheme_str_round_trip() {
89 let _guard = lock_or_recover();
90 assert_eq!(AuthScheme::Anonymous.as_str(), "anonymous");
91 assert_eq!(AuthScheme::Basic.as_str(), "basic");
92 assert_eq!(AuthScheme::Bearer.as_str(), "bearer");
93 assert_eq!(AuthScheme::Ssh.as_str(), "ssh");
94 assert_eq!(AuthScheme::default(), AuthScheme::Anonymous);
95}
96
97#[test]
98fn auth_context_anonymous_has_no_principal() {
99 let ctx = AuthContext::anonymous();
100 assert_eq!(ctx.scheme, AuthScheme::Anonymous);
101 assert!(ctx.principal.is_none());
102 assert!(ctx.raw.is_none());
103}
104
105#[test]
106fn auth_context_with_principal_keeps_scheme_and_principal() {
107 let ctx = AuthContext::with_principal(
108 AuthScheme::Bearer,
109 Principal::new("alice", AuthScheme::Bearer),
110 );
111 assert_eq!(ctx.scheme, AuthScheme::Bearer);
112 let p = ctx.principal.expect("principal present");
113 assert_eq!(p.name, "alice");
114 assert_eq!(p.scheme, AuthScheme::Bearer);
115}
116
117#[test]
118fn ref_update_batch_push_and_count() {
119 let batch = RefUpdateBatch::new()
120 .push(
121 "refs/heads/main",
122 "0000000000000000000000000000000000000000",
123 None,
124 )
125 .push(
126 "refs/heads/dev",
127 "1111111111111111111111111111111111111111",
128 Some("0000000000000000000000000000000000000000".to_string()),
129 );
130
131 assert_eq!(batch.len(), 2);
132 assert!(!batch.is_empty());
133 assert_eq!(batch.updates.len(), 2);
134 assert_eq!(batch.updates[0].name, "refs/heads/main");
135 assert!(batch.updates[0].old_oid.is_none());
136 assert!(batch.updates[1].old_oid.is_some());
137}
138
139#[test]
140fn stream_guard_cancel_is_observable_via_handle() {
141 let guard = StreamGuard::new();
142 let handle = guard.handle();
143 assert!(!guard.is_cancelled());
144 assert!(!handle.is_cancelled());
145
146 guard.cancel();
147 assert!(guard.is_cancelled());
148 assert!(handle.is_cancelled());
149
150 handle.cancel();
151 assert!(guard.is_cancelled());
152}
153
154#[test]
155fn server_request_builder_populates_fields() {
156 let req = make_request("GET", "/info/refs", EndpointKind::InfoRefs)
157 .with_query("service=git-upload-pack")
158 .with_header(
159 "Content-Type",
160 "application/x-git-upload-pack-advertisement",
161 )
162 .with_auth(AuthContext::anonymous())
163 .with_output_cap(1024)
164 .with_remote_addr("127.0.0.1:9418");
165
166 assert_eq!(req.method, "GET");
167 assert_eq!(req.path, "/info/refs");
168 assert_eq!(req.query.as_deref(), Some("service=git-upload-pack"));
169 assert_eq!(
170 req.headers.get("Content-Type").map(String::as_str),
171 Some("application/x-git-upload-pack-advertisement")
172 );
173 assert_eq!(req.output_cap, 1024);
174 assert_eq!(req.remote_addr.as_deref(), Some("127.0.0.1:9418"));
175 assert_eq!(req.endpoint, EndpointKind::InfoRefs);
176 assert_eq!(req.service, "test-service");
177}
178
179#[test]
180fn stream_outcome_bytes_round_trip() {
181 assert_eq!(StreamOutcome::Eof.bytes(), None);
182 assert_eq!(StreamOutcome::Bytes(7).bytes(), Some(7));
183}
184
185#[test]
186fn pack_stats_default_is_empty() {
187 let s = PackStats::default();
188 assert!(s.is_empty());
189 assert_eq!(s.bytes_streamed, 0);
190 assert_eq!(s.objects_reported, 0);
191}
192
193#[test]
194fn ingest_report_empty_is_default() {
195 let r = IngestReport::empty();
196 assert!(r.refs_updated.is_empty());
197 assert!(r.stats.is_empty());
198}
199
200#[test]
201fn empty_readers_and_helpers_are_present() {
202 let r1 = empty_reader();
203 let r2 = buf_empty_reader();
204 let rt = tokio::runtime::Builder::new_current_thread()
205 .enable_all()
206 .build()
207 .expect("rt");
208 let n1 = rt.block_on(async {
209 let mut r = r1;
210 let mut buf = [0u8; 4];
211 r.read(&mut buf).await.unwrap_or(0)
212 });
213 let n2 = rt.block_on(async {
214 let mut r = r2;
215 let mut buf = [0u8; 4];
216 r.read(&mut buf).await.unwrap_or(0)
217 });
218 assert_eq!(n1, 0);
219 assert_eq!(n2, 0);
220}
221
222#[tokio::test]
223async fn backend_placeholders_report_unimplemented() {
224 let backend = PlaceholderBackend;
225 let req = make_request("GET", "/info/refs", EndpointKind::InfoRefs);
226 let err = backend
227 .authorize(&req)
228 .await
229 .expect_err("placeholder must return Err");
230 match err {
231 ServerError::Unimplemented(msg) => assert!(msg.contains("authorize")),
232 other => panic!("unexpected error variant: {:?}", other),
233 }
234}
235
236#[tokio::test]
237async fn child_pack_reader_reads_bytes_then_reports_eof() {
238 use std::io::Cursor;
239 let data: Vec<u8> = (0u8..16).collect();
240 let reader = ChildPackReader::new(tokio::io::BufReader::new(Cursor::new(data.clone())), None);
241
242 let mut total: u64 = 0;
243 let mut reader = reader;
244 loop {
245 let mut buf = [0u8; 4];
246 let n = reader.next_chunk(&mut buf).await.expect("read ok");
247 if n == 0 {
248 break;
249 }
250 total = total.saturating_add(n as u64);
251 }
252 assert_eq!(total, 16);
253 assert!(reader.finished());
254}
255
256#[tokio::test]
257async fn child_pack_reader_collects_pack_stats_and_enforces_cap() {
258 use std::io::Cursor;
259 let data: Vec<u8> = (0u8..32).collect();
260 let mut reader = ChildPackReader::new(tokio::io::BufReader::new(Cursor::new(data)), None);
261 let stats = reader
262 .collect_pack_stats(Some(64))
263 .await
264 .expect("within cap");
265 assert_eq!(stats.bytes_streamed, 32);
266
267 let data: Vec<u8> = (0u8..32).collect();
268 let mut reader = ChildPackReader::new(tokio::io::BufReader::new(Cursor::new(data)), None);
269 let err = reader
270 .collect_pack_stats(Some(8))
271 .await
272 .expect_err("cap should reject");
273 match err {
274 ServerError::PayloadTooLarge { .. } => {}
275 other => panic!("unexpected error: {:?}", other),
276 }
277}
278
279#[tokio::test]
280async fn child_pack_writer_copies_reader_to_writer_and_finishes() {
281 use std::io::Cursor;
282 use tokio::io::BufWriter;
283
284 let src: Vec<u8> = (0u8..8).collect();
285 let mut src_reader = tokio::io::BufReader::new(Cursor::new(src));
286 let dst: Vec<u8> = Vec::new();
287 let buf_dst = BufWriter::new(Cursor::new(dst));
288
289 let mut writer = ChildPackWriter::new(buf_dst, None);
290 let outcome = writer.copy_from(&mut src_reader).await.expect("copy ok");
291 match outcome {
292 StreamOutcome::Bytes(n) => assert_eq!(n, 8),
293 StreamOutcome::Eof => panic!("expected bytes outcome"),
294 }
295 writer.finish().await.expect("finish ok");
296}
297
298#[tokio::test]
299async fn server_request_body_streams_bytes() {
300 use std::io::Cursor;
301 let body_data: Vec<u8> = b"PACK".to_vec();
302 let req = ServerRequest::new(
303 "POST",
304 "/git-receive-pack",
305 EndpointKind::GitReceivePack,
306 "test",
307 Box::new(Cursor::new(body_data.clone())),
308 );
309
310 let mut body = req.body;
311 let mut got = Vec::new();
312 body.read_to_end(&mut got).await.expect("read body");
313 assert_eq!(got, body_data);
314}
315
316#[test]
317fn ref_update_entry_serialises_oid_strings_not_objectid() {
318 let entry = RefUpdateEntry {
319 name: "refs/heads/x".into(),
320 new_oid: "deadbeef".into(),
321 old_oid: Some("feedface".into()),
322 };
323 assert_eq!(entry.new_oid, "deadbeef");
324 assert_eq!(entry.old_oid.as_deref(), Some("feedface"));
325}
326
327#[test]
328fn endpoint_kind_str_matches_module_taxonomy() {
329 let _guard = lock_or_recover();
330 assert_eq!(EndpointKind::GitUploadPack.as_str(), "git-upload-pack");
331 assert_eq!(EndpointKind::GitReceivePack.as_str(), "git-receive-pack");
332 assert_eq!(EndpointKind::LfsBatch.as_str(), "lfs-batch");
333 assert_eq!(EndpointKind::LfsObjects.as_str(), "lfs-objects");
334 assert_eq!(EndpointKind::InfoRefs.as_str(), "info-refs");
335 assert_eq!(EndpointKind::SmartHttp.as_str(), "smart-http");
336 assert_eq!(EndpointKind::Custom.as_str(), "custom");
337}
338
339#[test]
340fn guard_clone_is_independent_until_cancel() {
341 let g = StreamGuard::new();
342 let g2 = g.clone();
343 g.cancel();
344 assert!(g.is_cancelled());
345 assert!(g2.is_cancelled());
346}
347
348#[test]
349fn auth_context_default_is_anonymous() {
350 let ctx = AuthContext::default();
351 assert_eq!(ctx.scheme, AuthScheme::Anonymous);
352 assert!(ctx.principal.is_none());
353}
354
355#[test]
356fn server_request_default_output_cap_matches_command_cap() {
357 let req = make_request("GET", "/", EndpointKind::Custom);
358 assert_eq!(
359 req.output_cap,
360 gitbaby::server::DEFAULT_OUTPUT_CAP_BYTES as u64
361 );
362}
363
364#[test]
365fn ref_update_batch_default_is_empty() {
366 let batch = RefUpdateBatch::default();
367 assert!(batch.is_empty());
368 assert_eq!(batch.len(), 0);
369}
370
371#[test]
372fn endpoint_kind_equality_works() {
373 assert_eq!(EndpointKind::InfoRefs, EndpointKind::InfoRefs);
374 assert_ne!(EndpointKind::InfoRefs, EndpointKind::SmartHttp);
375}
376
377#[tokio::test]
378async fn stream_guard_cancel_during_async_does_not_panic() {
379 let guard = Arc::new(StreamGuard::new());
380 let g2 = guard.clone();
381
382 let h = tokio::spawn(async move {
383 tokio::time::sleep(Duration::from_millis(200)).await;
384 g2.cancel();
385 });
386
387 tokio::time::sleep(Duration::from_millis(50)).await;
388 assert!(!guard.is_cancelled());
389
390 h.await.expect("task ok");
391 assert!(guard.is_cancelled());
392}
Added lib/gitbaby/tests/tree.rs lib/gitbaby/tests/tree.rs +169 −0 View lib/gitbaby/tests/tree.rs
@@ -0,0 +1,169 @@
1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicU32, Ordering};
6
7use async_trait::async_trait;
8
9use gitbaby::GitBaby;
10use gitbaby::commit::TreeEntryMode;
11use gitbaby::error::BabyError;
12use gitbaby::repo::RepositoryFacade;
13
14static COUNTER: AtomicU32 = AtomicU32::new(0);
15
16struct TestFacade {
17 dir: PathBuf,
18}
19
20#[async_trait]
21impl RepositoryFacade for TestFacade {
22 async fn git_repo_dir(&self) -> Result<PathBuf, BabyError> {
23 Ok(self.dir.clone())
24 }
25
26 async fn git_alternate_object_directories(&self) -> Result<Vec<PathBuf>, BabyError> {
27 Ok(Vec::new())
28 }
29
30 async fn gix_repo(&self) -> Result<gix::Repository, BabyError> {
31 gix::open(&self.dir).map_err(|e| BabyError::Custom(e.to_string()))
32 }
33}
34
35fn temp_repo() -> PathBuf {
36 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
37 let dir = std::env::temp_dir().join(format!("gitbaby-tree-test-{}-{n}", std::process::id()));
38 let _ = std::fs::remove_dir_all(&dir);
39 std::fs::create_dir_all(&dir).expect("mkdir repo");
40 let out = Command::new("git")
41 .args(["init", "-q"])
42 .current_dir(&dir)
43 .output()
44 .expect("git init failed");
45 assert!(out.status.success(), "git init: {:?}", out);
46 dir
47}
48
49fn cleanup(repo: &Path) {
50 let _ = std::fs::remove_dir_all(repo);
51}
52
53fn git_commit(repo: &Path, msg: &str) {
54 let out = Command::new("git")
55 .args([
56 "-c",
57 "user.name=T",
58 "-c",
59 "user.email=t@e",
60 "commit",
61 "-q",
62 "-m",
63 msg,
64 ])
65 .current_dir(repo)
66 .output()
67 .expect("git commit failed");
68 assert!(out.status.success(), "git commit `{msg}`: {:?}", out);
69}
70
71fn write_file(repo: &Path, rel: &str, content: &str) {
72 let p = repo.join(rel);
73 std::fs::create_dir_all(p.parent().expect("parent")).expect("mkdir");
74 std::fs::write(p, content).expect("write");
75}
76
77fn index_commit(repo: &Path, msg: &str) {
78 let out = Command::new("git")
79 .args(["add", "-A"])
80 .current_dir(repo)
81 .output()
82 .expect("git add failed");
83 assert!(out.status.success(), "git add: {:?}", out);
84 git_commit(repo, msg);
85}
86
87async fn baby_of(repo: PathBuf) -> GitBaby {
88 GitBaby::new(Arc::new(TestFacade { dir: repo }))
89}
90
91#[tokio::test]
92async fn tree_entries_with_latest_whole_tree() {
93 let repo = temp_repo();
94 write_file(&repo, "a.txt", "one");
95 write_file(&repo, "b.txt", "bee");
96 index_commit(&repo, "first");
97 write_file(&repo, "a.txt", "two");
98 index_commit(&repo, "second");
99 write_file(&repo, "c/d.txt", "dee");
100 index_commit(&repo, "third");
101
102 let baby = baby_of(repo.clone()).await;
103 let rows = baby
104 .tree_entries_with_latest("HEAD", None)
105 .await
106 .expect("list whole tree");
107
108 let by_name: HashMap<&str, &gitbaby::tree::TreeEntryWithLatest> =
109 rows.iter().map(|r| (r.entry.name.as_str(), r)).collect();
110
111 let a = by_name["a.txt"];
112 assert_eq!(a.entry.mode, TreeEntryMode::Blob);
113 assert_eq!(a.latest.message.as_str(), "second");
114
115 let b = by_name["b.txt"];
116 assert_eq!(b.latest.message.as_str(), "first");
117
118 let c = by_name["c"];
119 assert_eq!(c.entry.mode, TreeEntryMode::Tree);
120 assert_eq!(c.latest.message.as_str(), "third");
121
122 let d = by_name["c/d.txt"];
123 assert_eq!(d.latest.message.as_str(), "third");
124
125 cleanup(&repo);
126}
127
128#[tokio::test]
129async fn tree_entries_with_latest_subtree() {
130 let repo = temp_repo();
131 write_file(&repo, "a.txt", "one");
132 index_commit(&repo, "first");
133 write_file(&repo, "c/d.txt", "dee");
134 index_commit(&repo, "third");
135
136 let baby = baby_of(repo.clone()).await;
137 let rows = baby
138 .tree_entries_with_latest("HEAD", Some("c"))
139 .await
140 .expect("list subtree");
141
142 assert_eq!(rows.len(), 2);
143 assert_eq!(rows[0].entry.name.as_str(), "c");
144 assert_eq!(rows[0].entry.mode, TreeEntryMode::Tree);
145 assert_eq!(rows[0].latest.message.as_str(), "third");
146 assert_eq!(rows[1].entry.name.as_str(), "c/d.txt");
147 assert_eq!(rows[1].latest.message.as_str(), "third");
148
149 cleanup(&repo);
150}
151
152#[tokio::test]
153async fn tree_entries_with_latest_rejects_bad_input() {
154 let repo = temp_repo();
155 write_file(&repo, "a.txt", "one");
156 index_commit(&repo, "first");
157
158 let baby = baby_of(repo.clone()).await;
159
160 assert!(baby.tree_entries_with_latest("", None).await.is_err());
161
162 assert!(
163 baby.tree_entries_with_latest("HEAD", Some(".."))
164 .await
165 .is_err()
166 );
167
168 cleanup(&repo);
169}
Added lib/gitbaby/AGENTS.md lib/gitbaby/AGENTS.md +52 −0 View lib/gitbaby/AGENTS.md
@@ -0,0 +1,52 @@
1# AGENTS.md — gitbaby
2
3## What this is
4- Single-crate Rust **library** (no binary, no workspace). Edition `2024`.
5- Public entrypoint: `GitBaby::new(facade: Arc<dyn RepositoryFacade>, pipe: Pipe)` in `src/lib.rs`.
6- Consumers **must implement** `RepositoryFacade` (`src/repo.rs`) — three async fns returning a path/paths and a `gix::Repository`. There is no default impl.
7
8## Build / test / verify
9- Standard cargo only. No CI, no pre-commit, no scripts, no rust-toolchain pin.
10- Edition 2024 requires a recent stable toolchain (rustc ≥ 1.85). Local env verified at `rustc 1.97.1`.
11- Useful commands (no order dependency beyond what's standard):
12 - `cargo build` / `cargo check` / `cargo build --release`
13 - `cargo test` (sync + `#[tokio::test]` integration tests in `tests/`)
14 - `cargo test --test <file>` to run a single integration test file
15 - `cargo test <name>` to filter by test name substring
16 - `cargo clippy --all-targets -- -D warnings` if strict
17 - `cargo fmt --check` / `cargo fmt` (default rustfmt; no `rustfmt.toml`)
18
19## Architecture notes
20- **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.
21- `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.
22- `Cmd::Display` = `prog` + `config_args` + `args` (config_args are prepended to args at runtime).
23- `Pipe` is `Clone`; `cancel_pipe()` sets `cancelled=true` and sends `start_kill()` to every running child but does **not** remove them from `cmds`.
24
25## Module layout convention
26- Most feature modules follow `src/<feature>/{mod.rs, types.rs, helper.rs, usecase.rs}`; `mod.rs` re-exports the public types from `types.rs`.
27- **`src/share/` is the outlier**: it uses `{cmd.rs, env.rs, error.rs, path.rs}` instead. Don't "normalize" it.
28- `src/command/` uses `{cmd.rs, context.rs, env.rs, error.rs, pipe.rs}` and has its own `error.rs` separate from `src/error.rs`.
29
30## Test quirks (`tests/`)
31- `tests/cmd_run.rs` defines a local `unwrap_or_recover_or_panic!`-style macro for asserting command output — reuse it, don't reinvent.
32- `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.
33- `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.
34
35## Gotchas
36- No README. No docs beyond the code. Treat `Cargo.toml` + source as the only source of truth.
37- `gix = "0.86"`, `tokio = 1.53` (full features), `time = 0.3` — all recent; do not bump them speculatively.
38- Cache values that are serde-serialized (e.g. `CommitInfo`, `Vec<TreeEntry>`) follow a compatibility convention: fields are additive only — never rename/remove a field, and every new field must carry `#[serde(default)]` so stale cached payloads still deserialize.
39- The foyer-backed cache (`src/cache/`) is single-process: it uses a fixed on-disk directory per config and relies on in-process invalidation hooks (`hook_ref_updated`, `hook_blob_written`). It must not be shared across processes on the same repo, or metadata (rev→oid, blob-exists negatives) can go stale.
40- Public API surfaces async (`async_trait`); sync callers must use a runtime (tests use `#[tokio::test]`).
41
42## Planned but unimplemented (streaming-first server)
43- `src/server/` exists (mod.rs, backend.rs, endpoint.rs, error.rs, limits.rs, request.rs, stream.rs) but every `ServerBackend` method currently returns `ServerError::Unimplemented("…")` placeholders. Do not "fill them in" with arbitrary bodies — wait for a concrete git/lfs protocol task before adding logic.
44- Streaming-only contract is intentional:
45 - All request/response bodies cross the API as `Box<dyn AsyncRead + Unpin + Send + Sync>` (alias `BoxedAsyncRead`). Never introduce a `Vec<u8>` body, `ServerResponse::body`, or `read_to_end` convenience into the public surface.
46 - `HeaderMap = HashMap<String, String>` (no `http` crate).
47 - `output_cap` defaults to `crate::command::cmd::DEFAULT_OUTPUT_CAP_BYTES` (64 MiB). The server re-exports it as `server::DEFAULT_OUTPUT_CAP_BYTES`.
48 - `LFS` upload cap is `DEFAULT_LFS_OBJECT_CAP_BYTES` (5 GiB) — a separate constant, not a magic number.
49 - `RefUpdateBatch` is a server-local struct (oid strings, not `gix::ObjectId`) so the API does not depend on `gix` types. Do **not** alias `refs::types::RefUpdate` here.
50- `ServerBackend` async-trait method set: `authorize`, `list_refs_stream`, `produce_pack_stream`, `ingest_pack_stream`, `lfs_get_stream`, `lfs_put_stream`. Methods returning a body must return `BoxedAsyncRead`; ingest methods return `IngestReport`. The legacy `head_object` / `write_ref` / `get_object` shapes are intentionally removed.
51- `ServerRequest` is **not** `Clone` (body is a boxed reader); it carries a manual `Debug` impl. `StreamGuard` is `Clone + Send + Sync` and exposes `cancel()` / `handle()` so spawned workers can race the request.
52- Tests: `tests/server.rs` reuses the project-wide `static ENV_LOCK: Mutex<()>` pattern from `tests/env.rs` and the `tokio::time::sleep(Duration::from_millis(200))` cancel convention from `tests/pipe.rs`. Add new server tests there.
No newline at end of file
Added lib/gitbaby/Cargo.toml lib/gitbaby/Cargo.toml +13 −0 View lib/gitbaby/Cargo.toml
@@ -0,0 +1,13 @@
1[package]
2name = "gitbaby"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7tokio = { workspace = true, features = ["process", "io-util", "sync", "macros", "rt-multi-thread", "time", "fs"] }
8serde = { workspace = true, features = ["derive"] }
9serde_json = { workspace = true }
10gix = { workspace = true, features = ["serde"] }
11time = { workspace = true }
12async-trait = { workspace = true }
13foyer = { workspace = true }
No newline at end of file
Added lib/gitbaby/README.md lib/gitbaby/README.md +175 −0 View lib/gitbaby/README.md
@@ -0,0 +1,175 @@
1# gitbaby
2
3A Rust library that implements git server backend operations on top of
4[`gix`](https://github.com/GitoxideLabs/gitoxide) (`gitoxide`). It provides an
5async, streaming-first API for reading and writing repository objects, refs,
6trees, blobs, LFS pointers, archives, bundles and more — designed to be embedded
7in a git hosting product (servers, admin tools, mirror agents).
8
9## Status
10
11- **Library only.** No binary, no workspace. Single crate, edition `2024`
12 (requires rustc ≥ 1.85).
13- The streaming-first HTTP/git-protocol server surface (`src/server/`) exists as
14 a contract but is **not implemented yet** — see [Server (planned)](#server-planned).
15
16## Features
17
18| Module | Capabilities |
19|---|---|
20| `commit` | `get_commit`, `rev_parse`, `is_ancestor`, `list_commits`, `commit_stats`, `get_tree_entries`, `last_commit_for_path` |
21| `branch` | `list_branches`, `find_branch`, `branch_exists`, default-branch get/set, `create_branch`(`_force`), `delete_branch`, `rename_branch`, `update_head` |
22| `tag` | `list_tags`, `find_tag`, `tag_exists`, lightweight / annotated / signed tag creation, `verify_tag_signature`, `delete_tag` |
23| `refs` | `list_refs`, `ref_exists`, `resolve_revision`, `update_ref`, `delete_ref`, `find_refs_by_oid`, atomic `update_refs_atomic` / `delete_refs_atomic` |
24| `tree` | `tree_latest_commit`, `commit_tree` (fast tree writes), `walk_tree`, `tree_entries_with_latest`, `diff_trees` |
25| `blob` | streaming `get_blob` / `stream_blob`, `get_blob_size`, `blob_exists`, `write_blob`, `list_blobs` / `list_all_blobs`, LFS pointer listing (`list_lfs_pointers`, `list_all_lfs_pointers`, `get_lfs_pointers`) |
26| `diff` | `ChangedPath`-based diff requests (`DiffRequest`), `NumStat`, `ShortStat`, `RangeDiffSpec`, whitespace modes |
27| `merge` + `conflict` | `merge_base`, `merge_tree`, conflict listing (`list_conflict_files`), `resolve_conflicts` |
28| `compare` | `DivergeObject` — divergence analysis between refs |
29| `remote` | `add_remote` / `remove_remote`, `get_remote_address`, `fetch_remote_commit`, `find_remote_repository` (URL cap check), `find_remote_root_ref`, `update_remote_mirror` |
30| `archive` | `create_archive`, streaming `get_archive`, `create_bundle` |
31| `setup` | `init_repository`, `is_repository_exists`, `fast_import` (git-fast-import based bulk loading) |
32| `cleanup` | `prune`, `apply_bfg_object_map` (BFG-style object history rewriting) |
33| `config` | `config_get`/`set`/`add`/`unset_all`/`get_all`/`get_regexp` (repo + global scope), managed-config variants |
34| `blame` | `BlameHunk`-based async blame with `Range` limits |
35| `submodule` | `list_submodules`, `get_submodule` |
36| `command` | Low-level child git process execution: `Cmd` with order-preserving `Env`, streaming pipes, output cap, timeouts |
37| `cache` | foyer-backed LRU+Tiered cache for commits, tree entries, blob metadata, rev→oid mappings; LFS cache; per-config on-disk store |
38| `server` | **Planned** streaming git/http protocol surface (see below) |
39
40## Architecture
41
42```
43GitBaby (src/lib.rs) ← single public entrypoint
44 ├─ facade: Arc<dyn RepositoryFacade> ← consumer-provided repo access
45 └─ feature modules (src/<feature>/)
46 mod.rs → re-exports public types
47 types.rs → request/response structs (serde, additive-only fields)
48 helper.rs → validation + git command construction / error classification
49 usecase.rs→ `impl GitBaby { pub async fn ... }` ← the public API
50```
51
52- Every feature is a set of **methods implemented directly on `GitBaby`** via
53 `impl GitBaby` blocks in each `usecase.rs`. The struct itself is tiny:
54 just an `Arc<dyn RepositoryFacade>`.
55- **`RepositoryFacade`** (`src/repo.rs`) is consumer-implemented (`async_trait`):
56 `git_repo_dir()`, `git_alternate_object_directories()`, `gix_repo()`.
57 There is no default implementation.
58- Most read/write operations shell out to the system `git` binary through
59 `Cmd` (`src/command/cmd.rs`) — ordered env, stdin/stdout/stderr handling, and a
60 64 MiB output cap (`DEFAULT_OUTPUT_CAP_BYTES`, enforced as
61 `CmdError::PayloadTooLarge`). Metadata reads (commits, tree entries) go through
62 the `gix` object store, which is also the read path that feeds the cache.
63- Errors are hand-written: `BabyError` (`src/error.rs`, ~50 variants) and
64 `CmdError` (`src/command/error.rs`) implement `Display` + `std::error::Error`
65 manually. (`thiserror` is a declared dependency but deliberately unused.)
66- `Env` (`src/command/env.rs`) is an **order-preserving** `Vec<(String, String)>`;
67 `with` appends, `set` replaces in place. Env ordering matters for spawned processes.
68
69## Quick start
70
71```toml
72[dependencies]
73gitbaby = "0.1"
74gix = "0.86"
75async-trait = "0.1"
76tokio = { version = "1", features = ["full"] }
77```
78
79```rust
80use std::path::{Path, PathBuf};
81use std::sync::Arc;
82use async_trait::async_trait;
83use gitbaby::repo::RepositoryFacade;
84use gitbaby::error::BabyError;
85use gitbaby::GitBaby;
86
87struct MyRepo(PathBuf);
88
89#[async_trait]
90impl RepositoryFacade for MyRepo {
91 async fn git_repo_dir(&self) -> Result<PathBuf, BabyError> {
92 Ok(self.0.clone())
93 }
94 async fn git_alternate_object_directories(&self) -> Result<Vec<PathBuf>, BabyError> {
95 Ok(Vec::new())
96 }
97 async fn gix_repo(&self) -> Result<gix::Repository, BabyError> {
98 gix::open(&self.0).map_err(|e| {
99 BabyError::Custom(format!("open {}: {e}", self.0.display()))
100 })
101 }
102}
103
104#[tokio::main]
105async fn main() -> Result<(), BabyError> {
106 let git = GitBaby::new(Arc::new(MyRepo("/path/to/repo".into())));
107
108 let head = git.get_commit("HEAD").await?;
109 println!("{head:?}");
110
111 let tree = git.get_tree_entries("HEAD", Path::new("")).await?;
112 println!("{} entries", tree.len());
113
114 let oid = git.rev_parse("HEAD").await?;
115 println!("HEAD = {oid}");
116
117 let branches = git.list_branches(gitbaby::branch::ListBranchesOptions::default()).await?;
118 println!("{} branches", branches.len());
119 Ok(())
120}
121```
122
123All operations are async and need a Tokio runtime (`#[tokio::test]` / `#[tokio::main]`).
124
125## Server (planned)
126
127`src/server/` defines the streaming-first server contract but every
128`ServerBackend` method still returns `ServerError::Unimplemented(...)`. The
129contract is intentional:
130
131- Methods: `authorize`, `list_refs_stream`, `produce_pack_stream`,
132 `ingest_pack_stream`, `lfs_get_stream`, `lfs_put_stream` (async-trait).
133- All bodies cross the API as `Box<dyn AsyncRead + Unpin + Send + Sync>`
134 (`BoxedAsyncRead`) — **no `Vec<u8>` bodies**; ingest methods return
135 `IngestReport`.
136- `HeaderMap = HashMap<String, String>` (no `http` crate).
137- Output cap defaults to `DEFAULT_OUTPUT_CAP_BYTES` (64 MiB); LFS uploads cap at
138 `DEFAULT_LFS_OBJECT_CAP_BYTES` (5 GiB) — `server::DEFAULT_LFS_OBJECT_CAP_BYTES`.
139- `RefUpdateBatch` is server-local (oid strings, no `gix` types);
140 `ServerRequest` is not `Clone`; `StreamGuard` is `Clone + Send + Sync` with
141 `cancel()` / `handle()`.
142
143Do not implement this surface until a concrete git/lfs protocol task exists.
144
145## Cache
146
147- `src/cache/` is a **single-process** foyer-backed store: fixed on-disk
148 directory per config, in-process invalidation hooks (`hook_ref_updated`,
149 `hook_blob_written`). Do not share across processes on the same repo —
150 metadata (rev→oid, blob-exists negatives) can go stale.
151- Namespaces: commit info (`NS_COMMIT`), tree entries (`NS_TREE_ENTRIES`),
152 rev→oid (`NS_REV`), blob metadata/exists (`NS_BLOB`, `NS_BLOB_EXISTS`,
153 `NS_BLOB_SIZE`), LFS (`NS_LFS`).
154- Lifecycle: `init_global_cache` / `init_global_cache_async` /
155 `shutdown_global_cache` / `try_global_cache`; `CachePools::open(cfg)` /
156 `close()`.
157
158## Compatibility conventions
159
160Serde-serialized cache values (`CommitInfo`, `Vec<TreeEntry>`, …) are
161**additive-only**: never rename or remove a field, and every new field must be
162`#[serde(default)]` so stale cached payloads still deserialize.
163
164## Testing
165
166```sh
167cargo test # unit + #[tokio::test] integration tests in tests/
168cargo test --test <file>
169cargo clippy --all-targets -- -D warnings
170cargo fmt --check
171```
172
173## License
174
175TBD.
No newline at end of file
Modified Cargo.lock Cargo.lock +1 −2 View Cargo.lock
@@ -3066,8 +3066,7 @@
30663066
30673067[[package]]
30683068name = "gitbaby"
3069version = "0.1.0"
3070source = "git+https://gitea.gitdata.me/GitDataAi/GitBaby.git?rev=69bd99a#69bd99a23d6302be9acfb353e8c680472e259e69"
3069version = "1.0.0"
30713070dependencies = [
30723071 "async-trait",
30733072 "foyer",
Modified Cargo.toml Cargo.toml +2 −1 View Cargo.toml
@@ -94,7 +94,8 @@
9494utoipa-swagger-ui = { version = "9", features = ["axum"] }
9595thiserror = "2.0.20"
9696gix = "0.86.0"
97gitbaby = { git="https://gitea.gitdata.me/GitDataAi/GitBaby.git", rev = "69bd99a" }
97gitbaby = { path = "lib/gitbaby" }
98foyer = { version = "0.22.3", features = ["runtime-tokio"] }
9899hayro = { version = "0.7.1", features = ["embed-fonts", "embed-cmaps"] }
99100wasm-bindgen = "0.2"
100101chrono = "0.4.45"