Skip to content
Clone

refactor(git): run git operations through KenShell over gRPC

Replace the local git backend (gitbaby + gix + spawned git processes) with
KenShell RPC calls:

- add src/rpcgit: KenShell-backed implementations of the previous gitbaby
  types and GitBaby methods; business services keep their DTOs and handlers
- switch tree/blob/commit/diff/refs/branches/tags/conflict/analysis/archive,
  remote, repository and commit-status paths to gRPC
- archive and remote import/fetch/push become async jobs polled through
  JobService
- remove smart HTTP, SSH, GraphQL, the sync worker, LFS object transfer and
  the git_env/gitcmd helpers from lib/git
- delete the lib/gitbaby crate and drop gix/russh/ssh-key/sha1/foyer from the
  workspace
- keep LFS locks, releases, stars, topics, watch and protection policies on
  the KenApi database

Capabilities without a KenShell RPC now return 501; see lib/git/todo.md.

Migrate downstream consumers: import worker uses StartRemoteImport, ci
changed-path detection uses DiffService, synchost drops the SyncWorker, and
app/githost is retired because KenShell serves smart HTTP and SSH.
ZhenYi committed
parent 2308dd0 commit 89109d2 Browse files

277 files changed

+2944 −15273

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 277
Deleted app/githost/src/main.rs
@@ -1,104 +0,0 @@
1use std::net::SocketAddr;
2use std::sync::Arc;
3
4use anyhow::Context;
5use config::scoped::{GithostConfig, ScopedConfig};
6use tokio::net::TcpListener;
7use tracing::{info, warn};
8
9#[tokio::main]
10async fn main() -> anyhow::Result<()> {
11 export::telemetry::init_tracing();
12
13 let cfg = GithostConfig::load().context("loading configuration")?;
14 if !cfg.githost.enabled() {
15 info!("githost is disabled by config; exiting");
16 return Ok(());
17 }
18
19 info!("connecting to database / redis / storage / email");
20 let state = export::AppState::with_config(cfg.clone().into_app_config())
21 .await
22 .context("building AppState")?;
23 info!("dependencies ready");
24
25 if let Err(err) = export::telemetry::init_meter_provider() {
26 warn!(error = %err, "failed to init meter provider; metrics disabled");
27 }
28
29 let git_svc = git::service::GitService::new(state.clone());
30 let sync_queue = Arc::new(git::sync::queue::SyncQueue::new(state.redis.clone()));
31 let smart_svc = git::smart::service::SmartService::new(git_svc)
32 .with_kv(Some(Arc::new(state.redis.clone())))
33 .with_queue(Some(sync_queue))
34 .with_sync_settings(cfg.git.sync.clone())
35 .with_ci_event_topic(cfg.ci.enabled.then(|| cfg.ci.event_queue_topic.clone()));
36 let ssh_svc = git::ssh::service::GitSshService::new(smart_svc.clone(), cfg.git.ssh.clone())
37 .with_sync_settings(cfg.git.sync.clone())
38 .with_ci_event_topic(cfg.ci.enabled.then(|| cfg.ci.event_queue_topic.clone()));
39 let smart_router =
40 git::smart::handler::router(smart_svc).merge(export::health::router(state.clone()));
41
42 let http_addr = SocketAddr::new(cfg.githost.http_bind_addr(), cfg.githost.http_port());
43 let http_listener = TcpListener::bind(http_addr)
44 .await
45 .with_context(|| format!("binding smart HTTP {http_addr}"))?;
46 info!(%http_addr, "githost smart HTTP listening");
47
48 let ssh_bind = cfg.git.ssh.bind_addr.clone();
49 let ssh_port = cfg.git.ssh.port;
50 let ssh_addr: SocketAddr = format!("{ssh_bind}:{ssh_port}")
51 .parse()
52 .with_context(|| format!("parsing ssh address {ssh_bind}:{ssh_port}"))?;
53 git::ssh::build_server_config(&cfg.git.ssh)
54 .with_context(|| format!("validating ssh server config for {ssh_addr}"))?;
55 let ssh_listener = TcpListener::bind(ssh_addr)
56 .await
57 .with_context(|| format!("binding SSH {ssh_addr}"))?;
58 info!(%ssh_addr, "githost SSH listening");
59
60 let (http_tx, http_rx) = tokio::sync::oneshot::channel::<()>();
61 let (ssh_tx, ssh_rx) = tokio::sync::oneshot::channel::<()>();
62
63 let http_task = tokio::spawn(async move {
64 let http_shutdown = async move {
65 let _ = http_rx.await;
66 };
67 axum::serve(http_listener, smart_router.into_make_service())
68 .with_graceful_shutdown(http_shutdown)
69 .await
70 .map_err(anyhow::Error::from)
71 });
72
73 let mut ssh_task = tokio::spawn(async move {
74 tokio::select! {
75 res = git::ssh::serve_on_listener(ssh_svc, ssh_listener) => res.map_err(anyhow::Error::from),
76 _ = ssh_rx => Ok(()),
77 }
78 });
79
80 let ssh_crashed = tokio::select! {
81 _ = export::telemetry::shutdown_signal() => false,
82 res = &mut ssh_task => {
83 match res {
84 Err(e) => warn!(error = %e, "ssh task panicked"),
85 Ok(Err(e)) => warn!(error = %e, "git ssh server exited unexpectedly"),
86 Ok(Ok(())) => {}
87 }
88 true
89 }
90 };
91 if ssh_crashed {
92 let _ = http_tx.send(());
93 let _ = http_task.await;
94 anyhow::bail!("git ssh server on {ssh_addr} exited; refusing to keep running without it");
95 }
96 info!("shutdown signal received; stopping listeners");
97 let _ = http_tx.send(());
98 let _ = ssh_tx.send(());
99
100 let _ = ssh_task.await;
101 http_task.await.context("smart HTTP task panicked")??;
102 info!("githost shut down");
103 Ok(())
104}
Deleted app/githost/Cargo.toml
@@ -1,14 +0,0 @@
1[package]
2name = "githost"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7git = { workspace = true }
8export = { workspace = true }
9config = { workspace = true }
10axum = { workspace = true }
11tokio = { workspace = true, features = ["signal", "net", "macros", "rt-multi-thread", "sync"] }
12tracing = { workspace = true }
13tracing-subscriber = { workspace = true }
14anyhow = { workspace = true }
No newline at end of file
Modified app/synchost/src/main.rs View app/synchost/src/main.rs
@@ -1,12 +1,12 @@
11use std::net::SocketAddr;
22use std::path::PathBuf;
33use std::sync::Arc;
4use std::time::Duration;
45
56use anyhow::Context;
6use cache::CacheClient;
77use config::scoped::{ScopedConfig, SynchostConfig};
8use git::sync::{RepoWebhookTx, SyncQueue, SyncWorker, WebhookTx};
98use import::ImportWorker;
9use kenshell::KenShellClient;
1010use search::{SearchService, SearchWorker};
1111use tokio::net::TcpListener;
1212use tokio_util::sync::CancellationToken;
@@ -27,8 +27,12 @@
2727 let redis = export::AppRedis::connect(cfg.redis.clone())
2828 .await
2929 .context("connecting to redis")?;
30 let cache = Arc::new(CacheClient::new(redis.clone()));
31 let repo_root = PathBuf::from(cfg.git.repo_root.clone());
30 let ks = KenShellClient::connect_with_key(
31 &cfg.git.kenshell_endpoint,
32 &cfg.git.kenshell_rpc_key,
33 Duration::from_secs(cfg.git.kenshell_timeout_secs.max(1)),
34 )
35 .context("building KenShell client")?;
3236 info!("dependencies ready");
3337
3438 if let Err(error) = export::telemetry::init_meter_provider() {
@@ -64,9 +68,7 @@
6468 let worker = ImportWorker::new(
6569 db.clone(),
6670 redis.clone(),
67 cache.clone(),
68 PathBuf::from(cfg.git.repo_root.clone()),
69 cfg.git.sync.clone(),
71 ks.clone(),
7072 cfg.import.clone(),
7173 cancel.clone(),
7274 );
@@ -92,38 +94,12 @@
9294 });
9395 }
9496
95 let queue = SyncQueue::new(redis.clone());
96 let webhook: Arc<dyn WebhookTx> = Arc::new(RepoWebhookTx::new(db.clone()));
97 let settings = cfg.git.sync.clone();
98
99 let worker = SyncWorker::new(
100 db,
101 redis,
102 cache,
103 repo_root,
104 queue,
105 webhook,
106 settings.clone(),
107 cancel.clone(),
108 );
109 let running = worker
110 .run()
111 .await
112 .map_err(|e| anyhow::anyhow!("starting sync worker: {e}"))?;
113 info!(
114 concurrency = settings.concurrency.max(1),
115 topic = %settings.queue_topic,
116 "synchost running"
117 );
97 info!("synchost running");
11898
11999 export::telemetry::shutdown_signal().await;
120100 info!("synchost shutting down");
121101
122102 cancel.cancel();
123 running
124 .shutdown()
125 .await
126 .map_err(|e| anyhow::anyhow!("worker shutdown: {e}"))?;
127103
128104 info!("synchost shut down");
129105 Ok(())
Modified app/synchost/Cargo.toml View app/synchost/Cargo.toml
@@ -6,6 +6,7 @@
66
77[dependencies]
88git = { workspace = true }
9kenshell = { workspace = true }
910export = { workspace = true }
1011config = { workspace = true }
1112cache = { workspace = true }
Modified lib/ci/examples/actions_fixture.rs View lib/ci/examples/actions_fixture.rs
@@ -72,7 +72,8 @@
7272 config.ci.event_queue_topic = "gitdata:ci-fixture:events".into();
7373 config.ci.lease_secs = 120;
7474 config.git.repo_root = directory.join("repos").to_string_lossy().into_owned();
75 config.git.base_url = "http://127.0.0.1:43121".into();
75 config.git.base_url =
76 std::env::var("FIXTURE_GIT_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:43121".into());
7677 let database = Database::connect(config.database.url.clone().unwrap()).await?;
7778 models::migrate::sync_schema(&database)
7879 .await
@@ -88,13 +89,6 @@
8889 .merge(ci::actions_runtime::router().with_state(service.clone()));
8990 let listener = tokio::net::TcpListener::bind("127.0.0.1:43120").await?;
9091 let server = tokio::spawn(async move { axum::serve(listener, http).await });
91 let git_service = git::service::GitService::new(service.state.clone());
92 let smart = git::smart::service::SmartService::new(git_service);
93 let git_listener = tokio::net::TcpListener::bind("127.0.0.1:43121").await?;
94 let git_server =
95 tokio::spawn(
96 async move { axum::serve(git_listener, git::smart::handler::router(smart)).await },
97 );
9892 let runner_id = Uuid::now_v7();
9993 let now = OffsetDateTime::now_utc();
10094 runners::ActiveModel {
@@ -215,7 +209,6 @@
215209 drive(&service, &client, &runner_config, approved).await?;
216210 println!("PASS: environment approval gate");
217211 server.abort();
218 git_server.abort();
219212 Ok(())
220213}
221214
Modified lib/ci/src/actions_events.rs View lib/ci/src/actions_events.rs
@@ -121,47 +121,42 @@
121121 repo_id: Uuid,
122122 seed: &RunSeed,
123123 ) -> CiResult<Vec<String>> {
124 let repo = models::repo::repos::Entity::find_by_id(repo_id)
125 .one(self.db_read())
126 .await?
127 .ok_or(CiError::NotFound)?;
128 let space = self.space_of_repo(&repo).await?;
129 let service = git::service::GitService::new(self.state.clone());
124130 let before = seed
125131 .base_sha
126132 .as_deref()
127 .or_else(|| seed.event_payload["old_oid"].as_str());
128 let mut command = tokio::process::Command::new("git");
129 command.arg("-C").arg(self.repo_dir(repo_id));
130 if let Some(before) =
131 before.filter(|oid| !oid.is_empty() && !oid.chars().all(|ch| ch == '0'))
132 {
133 command.arg("diff").arg("--name-only").arg("-z");
134 command
135 .arg(if seed.event == WorkflowEventKind::PullRequest {
136 format!("{before}...{}", seed.commit_sha)
137 } else {
138 format!("{before}..{}", seed.commit_sha)
139 })
140 .arg("--");
141 } else {
142 command
143 .arg("ls-tree")
144 .arg("-r")
145 .arg("--name-only")
146 .arg("-z")
147 .arg(&seed.commit_sha);
148 }
149 let output = command
150 .output()
151 .await
152 .map_err(|error| CiError::Internal(error.to_string()))?;
153 if !output.status.success() {
154 return Err(CiError::InvalidArgument(format!(
155 "workflow path diff failed: {}",
156 String::from_utf8_lossy(&output.stderr)
157 )));
158 }
159 Ok(output
160 .stdout
161 .split(|byte| *byte == 0)
162 .filter(|path| !path.is_empty())
163 .map(|path| String::from_utf8_lossy(path).into_owned())
164 .collect())
133 .or_else(|| seed.event_payload["old_oid"].as_str())
134 .filter(|oid| !oid.is_empty() && !oid.chars().all(|ch| ch == '0'));
135 let paths = match before {
136 Some(before) => {
137 service
138 .diff_files_changed_between(
139 space.lower_name,
140 repo.name,
141 before.to_string(),
142 seed.commit_sha.clone(),
143 )
144 .await?
145 }
146 None => service
147 .tree_walk(
148 space.lower_name,
149 repo.name,
150 seed.commit_sha.clone(),
151 true,
152 false,
153 )
154 .await?
155 .into_iter()
156 .map(|entry| entry.name)
157 .collect(),
158 };
159 Ok(paths)
165160 }
166161
167162 pub async fn sync_actions_schedules(
Modified lib/ci/src/workflows.rs View lib/ci/src/workflows.rs
@@ -171,8 +171,8 @@
171171 let mut out: Vec<String> = entries
172172 .into_iter()
173173 .filter(|entry| {
174 entry.mode == gitbaby::commit::types::TreeEntryMode::Blob
175 || entry.mode == gitbaby::commit::types::TreeEntryMode::Exec
174 entry.mode == git::rpcgit::commit::types::TreeEntryMode::Blob
175 || entry.mode == git::rpcgit::commit::types::TreeEntryMode::Exec
176176 })
177177 .map(|entry| {
178178 entry
@@ -218,8 +218,8 @@
218218 )
219219 .await
220220 .map_err(CiError::from)?;
221 if entry.mode != gitbaby::commit::types::TreeEntryMode::Blob
222 && entry.mode != gitbaby::commit::types::TreeEntryMode::Exec
221 if entry.mode != git::rpcgit::commit::types::TreeEntryMode::Blob
222 && entry.mode != git::rpcgit::commit::types::TreeEntryMode::Exec
223223 {
224224 return Err(CiError::InvalidArgument(format!(
225225 "`{path}` is not a regular file"
Modified lib/ci/Cargo.toml View lib/ci/Cargo.toml
@@ -8,7 +8,6 @@
88config = { workspace = true }
99export = { workspace = true }
1010git = { workspace = true }
11gitbaby = { workspace = true }
1211models = { workspace = true }
1312jwt = { workspace = true }
1413space = { workspace = true }
Modified lib/git/src/context/facade.rs View lib/git/src/context/facade.rs
@@ -1,12 +1,10 @@
1use async_trait::async_trait;
1use std::path::PathBuf;
2use std::sync::Arc;
3
24use cache::CacheClient;
35use export::AppDatabase;
4use gitbaby::error::BabyError;
5use gitbaby::repo::RepositoryFacade;
6use gix::Repository;
6use kenshell::proto::common::v1::{GitActor, RepositoryRef, WriteOptions};
77use models::repo::repos;
8use std::path::PathBuf;
9use std::sync::Arc;
108use uuid::Uuid;
119
1210#[derive(Clone)]
@@ -14,38 +12,50 @@
1412 pub model: repos::Model,
1513 pub cache: Arc<CacheClient>,
1614 pub database: Arc<AppDatabase>,
15 pub space_name: String,
1716 pub repo_root: PathBuf,
18 pub objects_root: PathBuf,
17 pub ks: kenshell::KenShellClient,
1918}
2019
2120impl GitContext {
21 pub fn repository_ref(&self) -> RepositoryRef {
22 RepositoryRef {
23 org_name: self.space_name.clone(),
24 repo_name: self.model.name.clone(),
25 }
26 }
27
28 pub fn actor(&self) -> GitActor {
29 GitActor {
30 user_id: 0,
31 username: String::new(),
32 }
33 }
34
35 pub fn write_options(&self) -> WriteOptions {
36 WriteOptions {
37 holder: format!("kenapi-{}", self.model.id),
38 lock_ttl: Some(prost_types::Duration {
39 seconds: 30,
40 nanos: 0,
41 }),
42 lock_wait: Some(prost_types::Duration {
43 seconds: 5,
44 nanos: 0,
45 }),
46 require_all_replicas: false,
47 }
48 }
49
2250 pub fn repo_dir(&self) -> PathBuf {
23 self.repo_root.join(self.model.id.to_string())
51 repo_dir_for(&self.repo_root, self.model.id)
2452 }
2553
2654 pub fn repo_uninitialized(&self) -> bool {
27 !self.repo_dir().join("HEAD").exists()
55 self.model.nums_commits == 0
2856 }
2957}
3058
3159pub fn repo_dir_for(repo_root: &std::path::Path, repo_id: Uuid) -> PathBuf {
3260 repo_root.join(repo_id.to_string())
33}
34
35#[async_trait]
36impl RepositoryFacade for GitContext {
37 async fn git_repo_dir(&self) -> Result<PathBuf, BabyError> {
38 self.repo_dir().canonicalize().map_err(|e| BabyError::Io {
39 path: Default::default(),
40 source: e,
41 })
42 }
43
44 async fn git_alternate_object_directories(&self) -> Result<Vec<PathBuf>, BabyError> {
45 Ok(vec![self.objects_root.clone()])
46 }
47
48 async fn gix_repo(&self) -> Result<Repository, BabyError> {
49 gix::open(&self.git_repo_dir().await?).map_err(|e| BabyError::InvalidGitArg(e.to_string()))
50 }
5161}
Deleted lib/git/src/graphql/query/context.rs
@@ -1,104 +0,0 @@
1use std::sync::Arc;
2
3use gitbaby::tags::types::TagType as GbTagType;
4
5use crate::context::facade::GitContext;
6use crate::graphql::error::{map_git_baby, to_gql};
7use crate::graphql::types::{BlobObj, CommitObj, GitObject, TagObj, TagShape, TreeObj};
8use crate::service::GitService;
9use crate::share::errors::GitError;
10
11pub const DEFAULT_FIRST: i32 = 30;
12pub const MAX_FIRST: i32 = 100;
13
14pub(crate) fn clamp_first(first: Option<i32>) -> i32 {
15 first.unwrap_or(DEFAULT_FIRST).clamp(0, MAX_FIRST)
16}
17
18pub(crate) fn parse_oid(hex: &str) -> Result<gix::hash::ObjectId, async_graphql::Error> {
19 gix::hash::ObjectId::from_hex(hex.as_bytes())
20 .map_err(|e| async_graphql::Error::new(format!("invalid object id `{hex}`: {e}")))
21}
22
23pub(crate) fn parse_cursor(c: Option<String>) -> Result<usize, async_graphql::Error> {
24 let Some(c) = c else { return Ok(0) };
25 c.parse::<usize>()
26 .map_err(|e| async_graphql::Error::new(format!("invalid cursor `{c}`: {e}")))
27}
28
29pub fn map_git(e: GitError) -> async_graphql::Error {
30 to_gql(e)
31}
32
33#[derive(Clone)]
34pub struct RepoCtx {
35 pub svc: Arc<GitService>,
36 pub ctx: GitContext,
37 pub space_name: String,
38 pub repo_name: String,
39 pub baby: gitbaby::GitBaby,
40}
41
42impl RepoCtx {
43 pub async fn parents_of(
44 &self,
45 oid: &gix::hash::ObjectId,
46 ) -> Result<Vec<gix::hash::ObjectId>, async_graphql::Error> {
47 let info = self
48 .baby
49 .get_commit(&oid.to_string())
50 .await
51 .map_err(map_git_baby)?;
52 Ok(info.parents)
53 }
54}
55
56pub(crate) const EMPTY_SHA1: [u8; 20] = [0; 20];
57
58pub(crate) fn parse_oid_or_default(s: &str) -> gix::hash::ObjectId {
59 gix::hash::ObjectId::from_hex(s.as_bytes())
60 .unwrap_or_else(|_| gix::hash::ObjectId::from_bytes_or_panic(&EMPTY_SHA1))
61}
62
63pub async fn resolve_git_object(
64 repo: &RepoCtx,
65 oid: gix::hash::ObjectId,
66) -> async_graphql::Result<GitObject> {
67 let baby = &repo.baby;
68 if let Ok(info) = baby
69 .get_commit(&oid.to_string())
70 .await
71 .map_err(map_git_baby)
72 {
73 return Ok(GitObject::Commit(CommitObj::from_info(&info)));
74 }
75 if let Ok(entries) = baby
76 .get_tree_entries(&oid.to_string(), std::path::Path::new(""))
77 .await
78 .map_err(map_git_baby)
79 {
80 return Ok(GitObject::Tree(TreeObj {
81 oid: crate::graphql::scalars::ObjectIdScalar(oid),
82 entry_count: entries.len() as i32,
83 }));
84 }
85 if let Ok(blob) = baby
86 .get_blob(oid, gitbaby::blob::types::GetBlobOptions { limit: None })
87 .await
88 .map_err(map_git_baby)
89 {
90 return Ok(GitObject::Blob(BlobObj::new(
91 blob.info.oid,
92 &blob.data,
93 blob.info.size,
94 )));
95 }
96 Ok(GitObject::Tag(TagObj::from(TagShape {
97 name: format!("tag:{}", oid.to_string().get(..7).unwrap_or("")),
98 id: oid,
99 target: oid,
100 tag_type: GbTagType::Annotated.into(),
101 message: None,
102 tagger: None,
103 })))
104}
Deleted lib/git/src/graphql/query/mod.rs
@@ -1,7 +0,0 @@
1mod context;
2mod repo;
3mod root;
4
5pub use context::{RepoCtx, map_git, resolve_git_object};
6pub use repo::Repo;
7pub use root::{GitSchema, Query};
Deleted lib/git/src/graphql/query/repo.rs
@@ -1,598 +0,0 @@
1use std::path::PathBuf;
2
3use async_graphql::Object;
4use gitbaby::diff::types::DiffRequest;
5use gitbaby::refs::types::{ListRefsOptions, RefType};
6use gitbaby::setup::types::ObjectFormat;
7
8use crate::graphql::error::map_git_baby;
9use crate::graphql::scalars::DateTimeScalar;
10use crate::graphql::types::{
11 BlameObj, BlobObj, ChangedPathDiffObj, CodeActivityObj, CommitObj, CommitStatusObj,
12 ComparisonObj, ComparisonStatus, ContributorStatObj, DiffObj, DiffSummaryObj, GitObject,
13 LanguageStatObj, LanguageStatsObj, LfsPointerObj, LicenseObj, LogOptionsInput, MergeStatusObj,
14 ObjectFormatEnum, PackStatsObj, PageInput, PatchObj, RefObj, RepoStatsObj, RepositoryInfoObj,
15 SubmoduleObj, TagObj, TagShape, TagTypeEnum, TopicObj, TreeObj,
16};
17use crate::service::diffs::DiffOptionsDto;
18
19use super::context::{
20 EMPTY_SHA1, RepoCtx, clamp_first, map_git, parse_cursor, parse_oid, parse_oid_or_default,
21 resolve_git_object,
22};
23
24pub struct Repo {
25 pub inner: RepoCtx,
26}
27
28impl Repo {
29 fn baby(&self) -> &gitbaby::GitBaby {
30 &self.inner.baby
31 }
32}
33
34fn parse_object_format(s: &str) -> ObjectFormat {
35 match s {
36 "sha256" => ObjectFormat::Sha256,
37 _ => ObjectFormat::Sha1,
38 }
39}
40
41#[Object]
42impl Repo {
43 async fn info(&self) -> RepositoryInfoObj {
44 RepositoryInfoObj {
45 id: self.inner.ctx.model.id.to_string(),
46 name: self.inner.ctx.model.name.clone(),
47 default_branch: self.inner.ctx.model.default_branch.clone(),
48 is_private: self.inner.ctx.model.is_private,
49 is_archived: self.inner.ctx.model.is_archived,
50 enable_lfs: self.inner.ctx.model.enable_lfs,
51 object_format: ObjectFormatEnum::from(parse_object_format(
52 &self.inner.ctx.model.object_format,
53 )),
54 branches_count: self.inner.ctx.model.nums_branches,
55 tags_count: self.inner.ctx.model.nums_tags,
56 commits_count: self.inner.ctx.model.nums_commits,
57 stars_count: self.inner.ctx.model.nums_star,
58 watchers_count: self.inner.ctx.model.nums_watch,
59 license_name: self.inner.ctx.model.license_name.clone(),
60 }
61 }
62
63 async fn license(&self) -> Result<Option<LicenseObj>, async_graphql::Error> {
64 let Some(name) = self.inner.ctx.model.license_name.clone() else {
65 return Ok(None);
66 };
67 let dto = self.inner.svc.get_license(&name).await.map_err(map_git)?;
68 Ok(Some(LicenseObj::from(models::repo::repo_license::Model {
69 name: dto.name,
70 display_name: dto.display_name,
71 description: dto.description,
72 body: dto.body,
73 url: dto.url,
74 created_at: dto.created_at,
75 updated_at: dto.updated_at,
76 })))
77 }
78
79 async fn topics(&self) -> Result<Vec<TopicObj>, async_graphql::Error> {
80 let dtos = self
81 .inner
82 .svc
83 .list_repo_topics(&self.inner.ctx)
84 .await
85 .map_err(map_git)?;
86 Ok(dtos
87 .into_iter()
88 .map(|t| TopicObj {
89 id: t.id.to_string(),
90 name: t.name,
91 created_at: DateTimeScalar::from(t.created_at),
92 updated_at: DateTimeScalar::from(t.updated_at),
93 })
94 .collect())
95 }
96
97 async fn repo_stats(&self) -> Result<RepoStatsObj, async_graphql::Error> {
98 let paths = self.inner.ctx.repo_dir();
99 let timeout = self.inner.svc.state.config.git.git_command_timeout_secs;
100 let out = crate::service::gitcmd::run_git_soft(
101 "graphql::repo_stats",
102 &paths,
103 vec!["count-objects".to_string(), "-v".to_string()],
104 &[],
105 timeout,
106 )
107 .await
108 .map_err(map_git_baby)?;
109 if !out.status.success() {
110 return Err(async_graphql::Error::new(
111 String::from_utf8_lossy(&out.stderr).into_owned(),
112 ));
113 }
114 let mut loose = 0i64;
115 let mut in_pack = 0i64;
116 let mut size = 0i64;
117 for line in String::from_utf8_lossy(&out.stdout).lines() {
118 if let Some(rest) = line.strip_prefix("count: ") {
119 loose = rest.trim().parse().unwrap_or(0);
120 } else if let Some(rest) = line.strip_prefix("in-pack: ") {
121 in_pack = rest.trim().parse().unwrap_or(0);
122 } else if let Some(rest) = line.strip_prefix("size: ") {
123 size = rest.trim().parse().unwrap_or(0);
124 }
125 }
126 let total = loose + in_pack;
127 Ok(RepoStatsObj {
128 git_size: size,
129 size,
130 lfs_size: self.inner.ctx.model.lfs_size,
131 object_count: total,
132 loose_object_count: loose,
133 pack_count: in_pack,
134 })
135 }
136
137 async fn pack_stats(&self) -> Result<PackStatsObj, async_graphql::Error> {
138 let s = self
139 .inner
140 .svc
141 .pack_stats(self.inner.space_name.clone(), self.inner.repo_name.clone())
142 .await
143 .map_err(map_git)?;
144 Ok(PackStatsObj {
145 objects: s.loose_count,
146 size: s.size_bytes,
147 delta_count: 0,
148 })
149 }
150
151 async fn object(&self, oid: String) -> Result<GitObject, async_graphql::Error> {
152 let id = parse_oid(&oid)?;
153 resolve_git_object(&self.inner, id).await
154 }
155
156 async fn commit(
157 &self,
158 oid: Option<String>,
159 rev: Option<String>,
160 ) -> Result<CommitObj, async_graphql::Error> {
161 let spec = oid
162 .or(rev)
163 .ok_or_else(|| async_graphql::Error::new("either `oid` or `rev` must be provided"))?;
164 let info = self.baby().get_commit(&spec).await.map_err(map_git_baby)?;
165 Ok(CommitObj::from_info(&info))
166 }
167
168 async fn blob(&self, oid: String) -> Result<BlobObj, async_graphql::Error> {
169 let id = parse_oid(&oid)?;
170 let cap = self
171 .inner
172 .svc
173 .state
174 .config
175 .git
176 .max_blob_size
177 .min(i64::MAX as u64) as i64;
178 let blob = self
179 .baby()
180 .get_blob(
181 id,
182 gitbaby::blob::types::GetBlobOptions { limit: Some(cap) },
183 )
184 .await
185 .map_err(map_git_baby)?;
186 Ok(BlobObj::new(blob.info.oid, &blob.data, blob.info.size))
187 }
188
189 async fn tree(&self, oid: String) -> Result<TreeObj, async_graphql::Error> {
190 let id = parse_oid(&oid)?;
191 let entries = self
192 .baby()
193 .get_tree_entries(&oid, std::path::Path::new(""))
194 .await
195 .map_err(map_git_baby)?;
196 Ok(TreeObj {
197 oid: crate::graphql::scalars::ObjectIdScalar(id),
198 entry_count: entries.len() as i32,
199 })
200 }
201
202 async fn tag(&self, name: String) -> Result<TagObj, async_graphql::Error> {
203 let dto = self
204 .inner
205 .svc
206 .get_tag(
207 self.inner.space_name.clone(),
208 self.inner.repo_name.clone(),
209 name,
210 )
211 .await
212 .map_err(map_git)?;
213 let target_oid = parse_oid(&dto.target_sha)
214 .map_err(|_| async_graphql::Error::new("tag target is not a valid oid"))?;
215 Ok(TagObj::from(TagShape {
216 name: dto.name.clone(),
217 id: target_oid,
218 target: target_oid,
219 tag_type: TagTypeEnum::from(dto.tag_type),
220 message: dto.message.clone(),
221 tagger: None,
222 }))
223 }
224
225 async fn tags(&self, page: Option<PageInput>) -> Result<Vec<TagObj>, async_graphql::Error> {
226 let first = clamp_first(page.as_ref().and_then(|p| p.first));
227 let offset = parse_cursor(page.as_ref().and_then(|p| p.after.clone()))?;
228 let dtos = self
229 .inner
230 .svc
231 .list_tags(self.inner.space_name.clone(), self.inner.repo_name.clone())
232 .await
233 .map_err(map_git)?;
234 Ok(dtos
235 .into_iter()
236 .skip(offset)
237 .take(first as usize)
238 .map(|t| {
239 TagObj::from(TagShape {
240 name: t.name.clone(),
241 id: parse_oid_or_default(&t.target_sha),
242 target: parse_oid_or_default(&t.target_sha),
243 tag_type: TagTypeEnum::from(t.tag_type),
244 message: t.message.clone(),
245 tagger: None,
246 })
247 })
248 .collect())
249 }
250
251 async fn ref_(&self, name: String) -> Result<RefObj, async_graphql::Error> {
252 let info = self
253 .baby()
254 .list_refs(ListRefsOptions {
255 patterns: vec![name],
256 ..Default::default()
257 })
258 .await
259 .map_err(map_git_baby)?
260 .into_iter()
261 .next()
262 .ok_or_else(|| async_graphql::Error::new("ref not found"))?;
263 Ok(RefObj::from(&info))
264 }
265
266 async fn refs(
267 &self,
268 pattern: Option<String>,
269 page: Option<PageInput>,
270 ) -> Result<Vec<RefObj>, async_graphql::Error> {
271 let first = clamp_first(page.as_ref().and_then(|p| p.first));
272 let offset = parse_cursor(page.as_ref().and_then(|p| p.after.clone()))?;
273 let info = self
274 .baby()
275 .list_refs(ListRefsOptions {
276 patterns: vec![pattern.unwrap_or_default()],
277 ..Default::default()
278 })
279 .await
280 .map_err(map_git_baby)?;
281 Ok(info
282 .into_iter()
283 .skip(offset)
284 .take(first as usize)
285 .map(|r| RefObj::from(&r))
286 .collect())
287 }
288
289 async fn branches(&self, page: Option<PageInput>) -> Result<Vec<RefObj>, async_graphql::Error> {
290 let first = clamp_first(page.as_ref().and_then(|p| p.first));
291 let offset = parse_cursor(page.as_ref().and_then(|p| p.after.clone()))?;
292 let info = self
293 .baby()
294 .list_refs(ListRefsOptions::default())
295 .await
296 .map_err(map_git_baby)?;
297 Ok(info
298 .into_iter()
299 .filter(|r| matches!(r.ref_type, RefType::Branch))
300 .skip(offset)
301 .take(first as usize)
302 .map(|r| RefObj::from(&r))
303 .collect())
304 }
305
306 async fn log(
307 &self,
308 opts: Option<LogOptionsInput>,
309 ) -> Result<Vec<CommitObj>, async_graphql::Error> {
310 let opts = opts.unwrap_or_default();
311 let first = clamp_first(opts.first);
312 let skip = parse_cursor(opts.after.clone())? as u64;
313 let dto = self
314 .inner
315 .svc
316 .log_list(
317 self.inner.space_name.clone(),
318 self.inner.repo_name.clone(),
319 crate::service::logs::LogOptions {
320 revision: opts.rev.clone(),
321 limit: Some(first as u64),
322 skip: Some(skip),
323 },
324 )
325 .await
326 .map_err(map_git)?;
327 Ok(dto.commits.iter().map(CommitObj::from_info).collect())
328 }
329
330 async fn diff(&self, base: String, head: String) -> Result<DiffObj, async_graphql::Error> {
331 let _ = DiffRequest {
332 left: self.baby().rev_parse(&base).await.map_err(map_git_baby)?,
333 right: self.baby().rev_parse(&head).await.map_err(map_git_baby)?,
334 };
335 let files = self
336 .inner
337 .svc
338 .diff_files(
339 self.inner.space_name.clone(),
340 self.inner.repo_name.clone(),
341 base.clone(),
342 head.clone(),
343 DiffOptionsDto::default(),
344 )
345 .await
346 .map_err(map_git)?;
347 let file_objs: Vec<ChangedPathDiffObj> =
348 files.iter().map(ChangedPathDiffObj::from).collect();
349 Ok(DiffObj {
350 start: base,
351 end: head,
352 files: file_objs,
353 is_incomplete: false,
354 })
355 }
356
357 async fn diff_short_stat(
358 &self,
359 base: String,
360 head: String,
361 ) -> Result<DiffSummaryObj, async_graphql::Error> {
362 let dto = self
363 .inner
364 .svc
365 .diff_short_stat(
366 self.inner.space_name.clone(),
367 self.inner.repo_name.clone(),
368 base,
369 head,
370 )
371 .await
372 .map_err(map_git)?;
373 Ok(DiffSummaryObj {
374 num_files: dto.files_changed as i32,
375 total_addition: dto.additions as i32,
376 total_deletion: dto.deletions as i32,
377 })
378 }
379
380 async fn diff_files_changed(
381 &self,
382 base: String,
383 head: String,
384 ) -> Result<Vec<String>, async_graphql::Error> {
385 self.inner
386 .svc
387 .diff_files_changed_between(
388 self.inner.space_name.clone(),
389 self.inner.repo_name.clone(),
390 base,
391 head,
392 )
393 .await
394 .map_err(map_git)
395 }
396
397 async fn comparison(
398 &self,
399 base_ref: String,
400 head_ref: String,
401 ) -> Result<ComparisonObj, async_graphql::Error> {
402 let stat = self
403 .inner
404 .svc
405 .diff_short_stat(
406 self.inner.space_name.clone(),
407 self.inner.repo_name.clone(),
408 base_ref.clone(),
409 head_ref.clone(),
410 )
411 .await
412 .map_err(map_git)?;
413 let log = self
414 .inner
415 .svc
416 .log_commits_between(
417 self.inner.space_name.clone(),
418 self.inner.repo_name.clone(),
419 base_ref.clone(),
420 head_ref.clone(),
421 256,
422 )
423 .await
424 .map_err(map_git)?;
425 let status = if stat.additions == 0 && stat.deletions == 0 {
426 ComparisonStatus::Identical
427 } else if stat.additions > 0 && stat.deletions == 0 {
428 ComparisonStatus::Ahead
429 } else if stat.additions == 0 {
430 ComparisonStatus::Behind
431 } else {
432 ComparisonStatus::Diverged
433 };
434 Ok(ComparisonObj {
435 base_ref,
436 head_ref,
437 ahead: stat.additions as i32,
438 behind: stat.deletions as i32,
439 status,
440 num_files: stat.files_changed as i32,
441 commits: log.commits.iter().map(CommitObj::from_info).collect(),
442 })
443 }
444
445 async fn blame(&self, rev: String, path: String) -> Result<BlameObj, async_graphql::Error> {
446 let dtos = self
447 .inner
448 .svc
449 .blame(
450 self.inner.space_name.clone(),
451 self.inner.repo_name.clone(),
452 rev,
453 PathBuf::from(path),
454 None,
455 None,
456 )
457 .await
458 .map_err(map_git)?;
459 let hunks: Vec<gitbaby::blame::types::BlameHunk> = dtos
460 .into_iter()
461 .map(|r| gitbaby::blame::types::BlameHunk {
462 sha: gix::hash::ObjectId::from_hex(r.commit_sha.as_bytes())
463 .unwrap_or_else(|_| gix::hash::ObjectId::from_bytes_or_panic(&EMPTY_SHA1)),
464 previous_sha: None,
465 previous_path: r.path.map(std::path::PathBuf::from),
466 lines: Vec::new(),
467 final_start_line: r.starting_line.max(0) as u32,
468 final_line_count: (r.ending_line - r.starting_line + 1).max(0) as u32,
469 orig_start_line: 0,
470 })
471 .collect();
472 Ok(BlameObj::from_hunks(&hunks))
473 }
474
475 async fn languages(&self) -> Result<LanguageStatsObj, async_graphql::Error> {
476 let dtos = self
477 .inner
478 .svc
479 .analysis_languages_read(self.inner.space_name.clone(), self.inner.repo_name.clone())
480 .await
481 .map_err(map_git)?;
482 Ok(LanguageStatsObj {
483 total: dtos.len() as i32,
484 languages: dtos.iter().map(LanguageStatObj::from).collect(),
485 })
486 }
487
488 async fn contributors(&self) -> Result<Vec<ContributorStatObj>, async_graphql::Error> {
489 let dtos = self
490 .inner
491 .svc
492 .analysis_contributors(self.inner.space_name.clone(), self.inner.repo_name.clone())
493 .await
494 .map_err(map_git)?;
495 Ok(dtos.iter().map(ContributorStatObj::from).collect())
496 }
497
498 async fn code_activity(
499 &self,
500 branch: Option<String>,
501 ) -> Result<CodeActivityObj, async_graphql::Error> {
502 let dto = self
503 .inner
504 .svc
505 .analysis_code_activity(
506 self.inner.space_name.clone(),
507 self.inner.repo_name.clone(),
508 branch,
509 )
510 .await
511 .map_err(map_git)?;
512 Ok(CodeActivityObj::from(&dto))
513 }
514
515 async fn submodules(&self, rev: String) -> Result<Vec<SubmoduleObj>, async_graphql::Error> {
516 let dtos = self
517 .inner
518 .svc
519 .submodule_list(
520 self.inner.space_name.clone(),
521 self.inner.repo_name.clone(),
522 rev,
523 )
524 .await
525 .map_err(map_git)?;
526 Ok(dtos.iter().map(SubmoduleObj::from).collect())
527 }
528
529 async fn conflict_check(
530 &self,
531 base: String,
532 head: String,
533 ) -> Result<MergeStatusObj, async_graphql::Error> {
534 let dto = self
535 .inner
536 .svc
537 .conflict_check(
538 self.inner.space_name.clone(),
539 self.inner.repo_name.clone(),
540 base,
541 head,
542 )
543 .await
544 .map_err(map_git)?;
545 Ok(MergeStatusObj::from(&dto))
546 }
547
548 async fn patch_generate(
549 &self,
550 base: String,
551 head: String,
552 ) -> Result<PatchObj, async_graphql::Error> {
553 let dto = self
554 .inner
555 .svc
556 .patch_generate(
557 self.inner.space_name.clone(),
558 self.inner.repo_name.clone(),
559 base,
560 head,
561 )
562 .await
563 .map_err(map_git)?;
564 Ok(PatchObj::from(&dto))
565 }
566
567 async fn commit_statuses(
568 &self,
569 sha: String,
570 ) -> Result<Vec<CommitStatusObj>, async_graphql::Error> {
571 let dtos = self
572 .inner
573 .svc
574 .list_commit_statuses(
575 self.inner.space_name.clone(),
576 self.inner.repo_name.clone(),
577 sha,
578 )
579 .await
580 .map_err(map_git)?;
581 Ok(dtos.iter().map(CommitStatusObj::from).collect())
582 }
583
584 async fn lfs_pointers(&self, rev: String) -> Result<Vec<LfsPointerObj>, async_graphql::Error> {
585 let dtos = self
586 .inner
587 .svc
588 .list_lfs_pointers(
589 self.inner.space_name.clone(),
590 self.inner.repo_name.clone(),
591 rev,
592 None,
593 )
594 .await
595 .map_err(map_git)?;
596 Ok(dtos.iter().map(LfsPointerObj::from).collect())
597 }
598}
Deleted lib/git/src/graphql/query/root.rs
@@ -1,73 +0,0 @@
1use async_graphql::{Context, EmptyMutation, EmptySubscription, ErrorExtensions, Object, Schema};
2use std::sync::Arc;
3use uuid::Uuid;
4
5use crate::graphql::types::LicenseObj;
6use crate::service::GitService;
7use crate::share::access::authorize_repo_access;
8use crate::share::errors::GitError;
9
10use super::context::{RepoCtx, map_git};
11use super::repo::Repo;
12
13pub type GitSchema = Schema<Query, EmptyMutation, EmptySubscription>;
14
15pub struct Query;
16
17#[Object]
18impl Query {
19 async fn api_version(&self) -> &'static str {
20 "1.0.0"
21 }
22
23 async fn license(
24 &self,
25 ctx: &Context<'_>,
26 name: String,
27 ) -> Result<LicenseObj, async_graphql::Error> {
28 let svc = ctx.data_unchecked::<Arc<GitService>>().clone();
29 let dto = svc.get_license(&name).await.map_err(map_git)?;
30 Ok(LicenseObj::from(models::repo::repo_license::Model {
31 name: dto.name,
32 display_name: dto.display_name,
33 description: dto.description,
34 body: dto.body,
35 url: dto.url,
36 created_at: dto.created_at,
37 updated_at: dto.updated_at,
38 }))
39 }
40
41 async fn repo(
42 &self,
43 ctx: &Context<'_>,
44 space_name: String,
45 repo_name: String,
46 ) -> Result<Repo, async_graphql::Error> {
47 let svc = ctx.data_unchecked::<Arc<GitService>>().clone();
48 let ctx_data = svc
49 .context(space_name.clone(), repo_name.clone())
50 .await
51 .map_err(map_git)?;
52 if ctx_data.model.is_private {
53 let uid = ctx
54 .data::<Option<i64>>()
55 .ok()
56 .and_then(|o| *o)
57 .ok_or_else(|| GitError::Unauthorized.extend())?;
58 authorize_repo_access(&svc, uid, &ctx_data, false, false)
59 .await
60 .map_err(map_git)?;
61 }
62 let baby = svc.baby(&ctx_data);
63 Ok(Repo {
64 inner: RepoCtx {
65 svc,
66 ctx: ctx_data,
67 space_name,
68 repo_name,
69 baby,
70 },
71 })
72 }
73}
Deleted lib/git/src/graphql/types/analysis.rs
@@ -1,85 +0,0 @@
1use async_graphql::SimpleObject;
2
3use crate::service::analysis::{
4 CodeActivityAuthorDto, CodeActivityDto, ContributorStatDto, LanguageStatDto,
5};
6
7#[derive(SimpleObject, Clone, Debug)]
8pub struct LanguageStatObj {
9 pub language: String,
10 pub percentage_bp: i32,
11 pub size: i64,
12 pub is_primary: bool,
13}
14
15#[derive(SimpleObject, Clone, Debug)]
16pub struct LanguageStatsObj {
17 pub languages: Vec<LanguageStatObj>,
18 pub total: i32,
19}
20
21impl From<&LanguageStatDto> for LanguageStatObj {
22 fn from(s: &LanguageStatDto) -> Self {
23 LanguageStatObj {
24 language: s.language.clone(),
25 percentage_bp: s.percentage_bp,
26 size: s.size,
27 is_primary: s.is_primary,
28 }
29 }
30}
31
32#[derive(SimpleObject, Clone, Debug)]
33pub struct ContributorStatObj {
34 pub name: String,
35 pub email: String,
36 pub commits: i64,
37 pub additions: i64,
38 pub deletions: i64,
39}
40
41impl From<&ContributorStatDto> for ContributorStatObj {
42 fn from(s: &ContributorStatDto) -> Self {
43 ContributorStatObj {
44 name: s.name.clone(),
45 email: s.email.clone(),
46 commits: s.commits,
47 additions: s.additions,
48 deletions: s.deletions,
49 }
50 }
51}
52
53#[derive(SimpleObject, Clone, Debug)]
54pub struct CodeActivityAuthorObj {
55 pub name: String,
56 pub email: String,
57 pub commits: i64,
58}
59
60#[derive(SimpleObject, Clone, Debug)]
61pub struct CodeActivityObj {
62 pub authors: Vec<CodeActivityAuthorObj>,
63 pub total_commits: i64,
64 pub total_additions: i64,
65 pub total_deletions: i64,
66}
67
68impl From<&CodeActivityDto> for CodeActivityObj {
69 fn from(s: &CodeActivityDto) -> Self {
70 CodeActivityObj {
71 authors: s
72 .authors
73 .iter()
74 .map(|a: &CodeActivityAuthorDto| CodeActivityAuthorObj {
75 name: a.name.clone(),
76 email: a.email.clone(),
77 commits: a.commits,
78 })
79 .collect(),
80 total_commits: s.total_commits,
81 total_additions: s.total_additions,
82 total_deletions: s.total_deletions,
83 }
84 }
85}
Deleted lib/git/src/graphql/types/blame.rs
@@ -1,68 +0,0 @@
1use async_graphql::{ComplexObject, SimpleObject};
2use gitbaby::blame::types::BlameHunk;
3
4use crate::graphql::error::map_git_baby;
5use crate::graphql::query::RepoCtx;
6
7use super::commit::CommitObj;
8
9#[derive(SimpleObject, Clone, Debug)]
10#[graphql(complex)]
11pub struct BlameRangeObj {
12 pub starting_line: i32,
13 pub ending_line: i32,
14 pub path: Option<String>,
15 pub commit_sha: String,
16 pub abbreviated_oid: String,
17}
18
19impl From<&BlameHunk> for BlameRangeObj {
20 fn from(h: &BlameHunk) -> Self {
21 let hex = h.sha.to_string();
22 BlameRangeObj {
23 starting_line: h.final_start_line as i32,
24 ending_line: (h.final_start_line as i32 + h.final_line_count as i32).saturating_sub(1),
25 path: h
26 .previous_path
27 .clone()
28 .map(|p| p.to_string_lossy().into_owned()),
29 commit_sha: hex.clone(),
30 abbreviated_oid: hex.get(..7).unwrap_or("").to_string(),
31 }
32 }
33}
34
35#[derive(SimpleObject, Clone, Debug)]
36pub struct BlameObj {
37 pub ranges: Vec<BlameRangeObj>,
38 pub total_lines: i32,
39}
40
41impl BlameObj {
42 pub fn from_hunks(hunks: &[BlameHunk]) -> Self {
43 let total_lines = hunks
44 .iter()
45 .map(|h| (h.final_start_line + h.final_line_count) as i32)
46 .max()
47 .unwrap_or(0);
48 BlameObj {
49 ranges: hunks.iter().map(Into::into).collect(),
50 total_lines,
51 }
52 }
53}
54
55#[ComplexObject]
56impl BlameRangeObj {
57 async fn commit(&self, ctx: &async_graphql::Context<'_>) -> async_graphql::Result<CommitObj> {
58 let repo = ctx.data_unchecked::<RepoCtx>();
59 let oid = gix::hash::ObjectId::from_hex(self.commit_sha.as_bytes())
60 .map_err(|e| async_graphql::Error::new(format!("invalid commit oid: {e}")))?;
61 let info = repo
62 .baby
63 .get_commit(&oid.to_string())
64 .await
65 .map_err(map_git_baby)?;
66 Ok(CommitObj::from_info(&info))
67 }
68}
Deleted lib/git/src/graphql/types/blob.rs
@@ -1,49 +0,0 @@
1use async_graphql::SimpleObject;
2use base64::Engine;
3use base64::engine::general_purpose::STANDARD as BASE64_ENGINE;
4
5use crate::graphql::scalars::ObjectIdScalar;
6
7#[derive(SimpleObject, Clone, Debug)]
8pub struct BlobObj {
9 pub oid: ObjectIdScalar,
10 pub byte_size: i32,
11 pub is_binary: bool,
12 pub text: Option<String>,
13 pub raw_base64: String,
14}
15
16impl BlobObj {
17 pub fn new(oid: gix::hash::ObjectId, data: &[u8], size: i64) -> Self {
18 let is_binary = crate::service::blobs::is_binary(data);
19 let text = if is_binary {
20 None
21 } else {
22 String::from_utf8(data.to_vec()).ok()
23 };
24 BlobObj {
25 oid: ObjectIdScalar(oid),
26 byte_size: size as i32,
27 is_binary,
28 text,
29 raw_base64: BASE64_ENGINE.encode(data),
30 }
31 }
32}
33
34#[derive(SimpleObject, Clone, Debug)]
35pub struct LfsPointerObj {
36 pub version: String,
37 pub oid: Option<String>,
38 pub size: Option<i64>,
39}
40
41impl From<&crate::service::blobs::LfsPointerDto> for LfsPointerObj {
42 fn from(p: &crate::service::blobs::LfsPointerDto) -> Self {
43 LfsPointerObj {
44 version: p.version.clone(),
45 oid: p.oid.clone(),
46 size: p.size,
47 }
48 }
49}
Deleted lib/git/src/graphql/types/commit.rs
@@ -1,132 +0,0 @@
1use async_graphql::{ComplexObject, SimpleObject};
2use gitbaby::commit::types::CommitInfo;
3
4use crate::graphql::error::map_git_baby;
5use crate::graphql::query::{RepoCtx, map_git};
6use crate::graphql::scalars::{DateTimeScalar, ObjectIdScalar};
7
8use super::common::{CommitMessageObj, GitActor};
9use super::enums::CommitStatusStateEnum;
10
11#[derive(SimpleObject, Clone, Debug)]
12#[graphql(complex)]
13pub struct CommitObj {
14 pub oid: ObjectIdScalar,
15 pub abbreviated_oid: String,
16 pub message: CommitMessageObj,
17 pub author: GitActor,
18 pub committer: GitActor,
19 pub signature_validated: bool,
20 pub parents_count: i32,
21}
22
23impl CommitObj {
24 pub fn from_info(c: &CommitInfo) -> Self {
25 let hex = c.id.to_string();
26 let abbreviated = hex.get(..7).unwrap_or("").to_string();
27 CommitObj {
28 oid: ObjectIdScalar(c.id),
29 abbreviated_oid: abbreviated,
30 message: CommitMessageObj::from(c.message.as_str()),
31 author: GitActor::from(&c.author),
32 committer: GitActor::from(&c.committer),
33 signature_validated: false,
34 parents_count: c.parents.len() as i32,
35 }
36 }
37}
38
39#[ComplexObject]
40impl CommitObj {
41 async fn parents(
42 &self,
43 ctx: &async_graphql::Context<'_>,
44 first: Option<i32>,
45 ) -> async_graphql::Result<Vec<CommitObj>> {
46 let repo = ctx.data_unchecked::<RepoCtx>();
47 let parent_oids = repo.parents_of(&self.oid.0).await?;
48 let limit = first
49 .filter(|n| *n >= 0)
50 .map(|n| n as usize)
51 .unwrap_or(parent_oids.len());
52 let mut out = Vec::new();
53 for parent_oid in parent_oids.iter().take(limit) {
54 let info = repo
55 .baby
56 .get_commit(&parent_oid.to_string())
57 .await
58 .map_err(map_git_baby)?;
59 out.push(CommitObj::from_info(&info));
60 }
61 Ok(out)
62 }
63
64 async fn signature(
65 &self,
66 ctx: &async_graphql::Context<'_>,
67 ) -> async_graphql::Result<VerifyResultGql> {
68 let repo = ctx.data_unchecked::<RepoCtx>();
69 let result = repo
70 .svc
71 .signature_verify_commit(
72 repo.space_name.clone(),
73 repo.repo_name.clone(),
74 self.oid.0.to_string(),
75 )
76 .await
77 .map_err(map_git)?;
78 Ok(VerifyResultGql::from(&result))
79 }
80}
81
82#[derive(SimpleObject, Clone, Debug)]
83pub struct VerifyResultGql {
84 pub signed: bool,
85 pub valid: bool,
86 pub key_id: Option<String>,
87 pub signer: Option<String>,
88 pub error_message: Option<String>,
89}
90
91impl From<&crate::service::signatures::VerifyResultDto> for VerifyResultGql {
92 fn from(v: &crate::service::signatures::VerifyResultDto) -> Self {
93 VerifyResultGql {
94 signed: v.signed,
95 valid: v.valid,
96 key_id: v.key_id.clone(),
97 signer: v.signer.clone(),
98 error_message: v.error_message.clone(),
99 }
100 }
101}
102
103#[derive(SimpleObject, Clone, Debug)]
104pub struct CommitStatusObj {
105 pub state: CommitStatusStateEnum,
106 pub context: String,
107 pub description: Option<String>,
108 pub target_url: Option<String>,
109 pub creator_id: String,
110 pub created_at: DateTimeScalar,
111 pub updated_at: Option<DateTimeScalar>,
112}
113
114impl From<&crate::service::commits::CommitStatusDto> for CommitStatusObj {
115 fn from(s: &crate::service::commits::CommitStatusDto) -> Self {
116 CommitStatusObj {
117 state: CommitStatusStateEnum::from(s.state),
118 context: s.context.clone(),
119 description: s.description.clone(),
120 target_url: s.target_url.clone(),
121 creator_id: s.creator_id.to_string(),
122 created_at: DateTimeScalar {
123 timestamp: s.created_at.unix_timestamp(),
124 offset_seconds: s.created_at.offset().whole_seconds(),
125 },
126 updated_at: s.updated_at.map(|u| DateTimeScalar {
127 timestamp: u.unix_timestamp(),
128 offset_seconds: u.offset().whole_seconds(),
129 }),
130 }
131 }
132}
Deleted lib/git/src/graphql/types/common.rs
@@ -1,78 +0,0 @@
1use async_graphql::SimpleObject;
2use gitbaby::commit::types::Signature as GBSignature;
3
4use crate::graphql::scalars::DateTimeScalar;
5
6#[derive(SimpleObject, Clone, Debug)]
7pub struct GitActor {
8 pub name: String,
9 pub email: String,
10 pub date: DateTimeScalar,
11}
12
13impl From<&GBSignature> for GitActor {
14 fn from(s: &GBSignature) -> Self {
15 GitActor {
16 name: s.name.clone(),
17 email: s.email.clone(),
18 date: DateTimeScalar::from(s),
19 }
20 }
21}
22
23#[derive(SimpleObject, Clone, Debug)]
24pub struct TrailerObj {
25 pub key: String,
26 pub value: String,
27}
28
29#[derive(SimpleObject, Clone, Debug)]
30pub struct CommitMessageObj {
31 pub raw: String,
32 pub title: String,
33 pub body: String,
34 pub trailers: Vec<TrailerObj>,
35}
36
37/// gitbaby returns the raw commit message string. Parse title (first
38/// line), body (until first blank line or trailer-like `Key: value`), and
39/// trailers (post-blank `Key: value` pairs).
40pub fn parse_commit_message(raw: &str) -> CommitMessageObj {
41 let raw = raw.trim_end_matches('\n').to_string();
42 let mut lines = raw.split('\n');
43 let title = lines.next().unwrap_or("").to_string();
44 let rest: Vec<&str> = lines.collect();
45 let split_idx = rest.iter().position(|l| {
46 l.trim().is_empty() || (l.contains(": ") && !l.starts_with(' ') && !l.starts_with('#'))
47 });
48 let (body_lines, trailer_lines) = match split_idx {
49 Some(i) => rest.split_at(i),
50 None => (rest.as_slice(), &[][..]),
51 };
52 let body = body_lines.join("\n").trim().to_string();
53 let trailers = trailer_lines
54 .iter()
55 .filter_map(|l| {
56 let (k, v) = l.split_once(':')?;
57 if k.trim().is_empty() {
58 return None;
59 }
60 Some(TrailerObj {
61 key: k.trim().to_string(),
62 value: v.trim_start().trim().to_string(),
63 })
64 })
65 .collect();
66 CommitMessageObj {
67 raw,
68 title,
69 body,
70 trailers,
71 }
72}
73
74impl From<&str> for CommitMessageObj {
75 fn from(raw: &str) -> Self {
76 parse_commit_message(raw)
77 }
78}
Deleted lib/git/src/graphql/types/diff.rs
@@ -1,106 +0,0 @@
1use async_graphql::SimpleObject;
2use gitbaby::diff::types::{ChangedPath, ChangedPathDiff, NumStat, ShortStat};
3
4use crate::service::diffs::ChangedPathDiffDto;
5
6use super::enums::DiffFileTypeEnum;
7
8#[derive(SimpleObject, Clone, Debug)]
9pub struct ChangedPathDiffObj {
10 pub path: String,
11 pub old_path: Option<String>,
12 pub status: DiffFileTypeEnum,
13 pub lines_added: i64,
14 pub lines_removed: i64,
15 pub binary: bool,
16}
17
18impl From<&ChangedPathDiffDto> for ChangedPathDiffObj {
19 fn from(c: &ChangedPathDiffDto) -> Self {
20 let status = match c.path.status.as_str() {
21 "Added" => DiffFileTypeEnum::Add,
22 "Deleted" => DiffFileTypeEnum::Del,
23 "Modified" => DiffFileTypeEnum::Change,
24 "Renamed" => DiffFileTypeEnum::Rename,
25 "Copied" => DiffFileTypeEnum::Copy,
26 "TypeChanged" => DiffFileTypeEnum::TypeChanged,
27 _ => DiffFileTypeEnum::Change,
28 };
29 ChangedPathDiffObj {
30 path: c.path.new_path.clone(),
31 old_path: c.path.old_path.clone(),
32 status,
33 lines_added: c.lines_added as i64,
34 lines_removed: c.lines_removed as i64,
35 binary: c.binary,
36 }
37 }
38}
39
40impl From<&ChangedPathDiff> for ChangedPathDiffObj {
41 fn from(c: &ChangedPathDiff) -> Self {
42 ChangedPathDiffObj {
43 path: c.path.new_path.to_string_lossy().into_owned(),
44 old_path: c
45 .path
46 .old_path
47 .as_ref()
48 .map(|p| p.to_string_lossy().into_owned()),
49 status: DiffFileTypeEnum::from(c.path.status),
50 lines_added: c.lines_added as i64,
51 lines_removed: c.lines_removed as i64,
52 binary: c.binary,
53 }
54 }
55}
56
57impl From<&ChangedPath> for ChangedPathDiffObj {
58 fn from(c: &ChangedPath) -> Self {
59 ChangedPathDiffObj {
60 path: c.new_path.to_string_lossy().into_owned(),
61 old_path: c
62 .old_path
63 .as_ref()
64 .map(|p| p.to_string_lossy().into_owned()),
65 status: DiffFileTypeEnum::from(c.status),
66 lines_added: 0,
67 lines_removed: 0,
68 binary: false,
69 }
70 }
71}
72
73#[derive(SimpleObject, Clone, Debug)]
74pub struct DiffObj {
75 pub start: String,
76 pub end: String,
77 pub files: Vec<ChangedPathDiffObj>,
78 pub is_incomplete: bool,
79}
80
81#[derive(SimpleObject, Clone, Debug)]
82pub struct DiffSummaryObj {
83 pub num_files: i32,
84 pub total_addition: i32,
85 pub total_deletion: i32,
86}
87
88impl From<&ShortStat> for DiffSummaryObj {
89 fn from(s: &ShortStat) -> Self {
90 DiffSummaryObj {
91 num_files: s.files_changed as i32,
92 total_addition: s.additions as i32,
93 total_deletion: s.deletions as i32,
94 }
95 }
96}
97
98impl From<&NumStat> for DiffSummaryObj {
99 fn from(n: &NumStat) -> Self {
100 DiffSummaryObj {
101 num_files: 1,
102 total_addition: n.additions as i32,
103 total_deletion: n.deletions as i32,
104 }
105 }
106}
Deleted lib/git/src/graphql/types/enums.rs
@@ -1,169 +0,0 @@
1use async_graphql::Enum;
2use gitbaby::commit::types::TreeEntryMode;
3use gitbaby::diff::types::DiffStatus;
4use gitbaby::refs::types::RefType as GbRefType;
5use gitbaby::setup::types::ObjectFormat;
6use gitbaby::tags::types::TagType as GbTagType;
7
8mod gitbaby_object {
9 pub use gix::objs::Kind;
10}
11
12#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
13pub enum GitObjectType {
14 Blob,
15 Tree,
16 Commit,
17 Tag,
18}
19
20impl From<gitbaby_object::Kind> for GitObjectType {
21 fn from(k: gitbaby_object::Kind) -> Self {
22 match k {
23 gitbaby_object::Kind::Blob => Self::Blob,
24 gitbaby_object::Kind::Tree => Self::Tree,
25 gitbaby_object::Kind::Commit => Self::Commit,
26 gitbaby_object::Kind::Tag => Self::Tag,
27 }
28 }
29}
30
31#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
32pub enum ObjectFormatEnum {
33 Sha1,
34 Sha256,
35}
36
37impl From<ObjectFormat> for ObjectFormatEnum {
38 fn from(f: ObjectFormat) -> Self {
39 match f {
40 ObjectFormat::Sha1 => Self::Sha1,
41 ObjectFormat::Sha256 => Self::Sha256,
42 }
43 }
44}
45
46#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
47pub enum RefTypeEnum {
48 Branch,
49 Tag,
50 Remote,
51 Other,
52}
53
54impl From<GbRefType> for RefTypeEnum {
55 fn from(t: GbRefType) -> Self {
56 match t {
57 GbRefType::Branch => Self::Branch,
58 GbRefType::Tag => Self::Tag,
59 GbRefType::Remote => Self::Remote,
60 GbRefType::Other => Self::Other,
61 }
62 }
63}
64
65#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
66pub enum TagTypeEnum {
67 Lightweight,
68 Annotated,
69}
70
71impl From<GbTagType> for TagTypeEnum {
72 fn from(t: GbTagType) -> Self {
73 match t {
74 GbTagType::Lightweight => Self::Lightweight,
75 GbTagType::Annotated => Self::Annotated,
76 }
77 }
78}
79
80impl From<models::git::git_tags::TagType> for TagTypeEnum {
81 fn from(t: models::git::git_tags::TagType) -> Self {
82 use models::git::git_tags::TagType as T;
83 match t {
84 T::Lightweight => Self::Lightweight,
85 T::Annotated => Self::Annotated,
86 }
87 }
88}
89
90#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
91pub enum ComparisonStatus {
92 Identical,
93 Ahead,
94 Behind,
95 Diverged,
96}
97
98#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
99pub enum TreeEntryType {
100 Blob,
101 Exec,
102 Tree,
103 Symlink,
104 Submodule,
105}
106
107impl From<TreeEntryMode> for TreeEntryType {
108 fn from(m: TreeEntryMode) -> Self {
109 match m {
110 TreeEntryMode::Blob => Self::Blob,
111 TreeEntryMode::Exec => Self::Exec,
112 TreeEntryMode::Tree => Self::Tree,
113 TreeEntryMode::Symlink => Self::Symlink,
114 TreeEntryMode::Submodule => Self::Submodule,
115 TreeEntryMode::Commit => Self::Submodule,
116 }
117 }
118}
119
120#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
121pub enum DiffFileTypeEnum {
122 Add,
123 Change,
124 Del,
125 Rename,
126 Copy,
127 TypeChanged,
128}
129
130impl From<DiffStatus> for DiffFileTypeEnum {
131 fn from(s: DiffStatus) -> Self {
132 match s {
133 DiffStatus::Added => Self::Add,
134 DiffStatus::Deleted => Self::Del,
135 DiffStatus::Modified => Self::Change,
136 DiffStatus::TypeChanged => Self::TypeChanged,
137 DiffStatus::Renamed => Self::Rename,
138 DiffStatus::Copied => Self::Copy,
139 }
140 }
141}
142
143#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
144pub enum GitSignatureState {
145 Unsigned,
146 Valid,
147 Invalid,
148 Unknown,
149}
150
151#[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)]
152pub enum CommitStatusStateEnum {
153 Pending,
154 Success,
155 Failure,
156 Error,
157}
158
159impl From<crate::service::commits::CommitStatusDtoState> for CommitStatusStateEnum {
160 fn from(s: crate::service::commits::CommitStatusDtoState) -> Self {
161 use crate::service::commits::CommitStatusDtoState as S;
162 match s {
163 S::Pending => Self::Pending,
164 S::Success => Self::Success,
165 S::Failure => Self::Failure,
166 S::Error => Self::Error,
167 }
168 }
169}
Deleted lib/git/src/graphql/types/inputs.rs
@@ -1,15 +0,0 @@
1use async_graphql::InputObject;
2
3#[derive(InputObject, Clone, Debug, Default)]
4pub struct PageInput {
5 pub first: Option<i32>,
6 pub after: Option<String>,
7}
8
9#[derive(InputObject, Clone, Debug, Default)]
10pub struct LogOptionsInput {
11 pub first: Option<i32>,
12 pub after: Option<String>,
13 pub first_parent: Option<bool>,
14 pub rev: Option<String>,
15}
Deleted lib/git/src/graphql/types/license.rs
@@ -1,28 +0,0 @@
1use async_graphql::SimpleObject;
2
3use crate::graphql::scalars::DateTimeScalar;
4
5#[derive(SimpleObject, Clone, Debug)]
6pub struct LicenseObj {
7 pub name: String,
8 pub display_name: String,
9 pub description: Option<String>,
10 pub body: String,
11 pub url: Option<String>,
12 pub created_at: DateTimeScalar,
13 pub updated_at: DateTimeScalar,
14}
15
16impl From<models::repo::repo_license::Model> for LicenseObj {
17 fn from(m: models::repo::repo_license::Model) -> Self {
18 Self {
19 name: m.name,
20 display_name: m.display_name,
21 description: m.description,
22 body: m.body,
23 url: m.url,
24 created_at: DateTimeScalar::from(m.created_at),
25 updated_at: DateTimeScalar::from(m.updated_at),
26 }
27 }
28}
Deleted lib/git/src/graphql/types/merge.rs
@@ -1,20 +0,0 @@
1use async_graphql::SimpleObject;
2
3use crate::service::conflicts::MergeStatusDto;
4
5#[derive(SimpleObject, Clone, Debug)]
6pub struct MergeStatusObj {
7 pub mergeable: bool,
8 pub fast_forwardable: bool,
9 pub conflicts: Vec<String>,
10}
11
12impl From<&MergeStatusDto> for MergeStatusObj {
13 fn from(m: &MergeStatusDto) -> Self {
14 MergeStatusObj {
15 mergeable: m.mergeable,
16 fast_forwardable: m.fast_forwardable,
17 conflicts: m.conflicts.clone(),
18 }
19 }
20}
Deleted lib/git/src/graphql/types/mod.rs
@@ -1,42 +0,0 @@
1mod analysis;
2mod blame;
3mod blob;
4mod commit;
5mod common;
6mod diff;
7mod enums;
8mod inputs;
9mod license;
10mod merge;
11mod objects;
12mod patch;
13mod refs;
14mod repo;
15mod submodule;
16mod tag;
17mod topic;
18mod tree;
19
20pub use analysis::{
21 CodeActivityAuthorObj, CodeActivityObj, ContributorStatObj, LanguageStatObj, LanguageStatsObj,
22};
23pub use blame::{BlameObj, BlameRangeObj};
24pub use blob::{BlobObj, LfsPointerObj};
25pub use commit::{CommitObj, CommitStatusObj, VerifyResultGql};
26pub use common::{CommitMessageObj, GitActor, TrailerObj};
27pub use diff::{ChangedPathDiffObj, DiffObj, DiffSummaryObj};
28pub use enums::{
29 CommitStatusStateEnum, ComparisonStatus, DiffFileTypeEnum, GitObjectType, GitSignatureState,
30 ObjectFormatEnum, RefTypeEnum, TagTypeEnum, TreeEntryType,
31};
32pub use inputs::{LogOptionsInput, PageInput};
33pub use license::LicenseObj;
34pub use merge::MergeStatusObj;
35pub use objects::GitObject;
36pub use patch::PatchObj;
37pub use refs::RefObj;
38pub use repo::{ComparisonObj, PackStatsObj, RepoStatsObj, RepositoryInfoObj};
39pub use submodule::SubmoduleObj;
40pub use tag::{TagObj, TagShape};
41pub use topic::{RepoRelationTopicObj, TopicObj};
42pub use tree::{TreeEntryObj, TreeEntryWithLatestObj, TreeObj};
Deleted lib/git/src/graphql/types/objects.rs
@@ -1,15 +0,0 @@
1use async_graphql::Union;
2
3use super::blob::BlobObj;
4use super::commit::CommitObj;
5use super::tag::TagObj;
6use super::tree::TreeObj;
7
8#[derive(Union, Clone, Debug)]
9#[graphql(name = "GitObject")]
10pub enum GitObject {
11 Commit(CommitObj),
12 Blob(BlobObj),
13 Tree(TreeObj),
14 Tag(TagObj),
15}
Deleted lib/git/src/graphql/types/patch.rs
@@ -1,18 +0,0 @@
1use async_graphql::SimpleObject;
2
3use crate::service::patches::PatchDto;
4
5#[derive(SimpleObject, Clone, Debug)]
6pub struct PatchObj {
7 pub content: String,
8 pub size: i64,
9}
10
11impl From<&PatchDto> for PatchObj {
12 fn from(p: &PatchDto) -> Self {
13 PatchObj {
14 content: p.content.clone(),
15 size: p.size,
16 }
17 }
18}
Deleted lib/git/src/graphql/types/refs.rs
@@ -1,55 +0,0 @@
1use async_graphql::{ComplexObject, SimpleObject};
2use gitbaby::refs::types::RefInfo;
3
4use crate::graphql::query::{RepoCtx, resolve_git_object};
5use crate::graphql::scalars::ObjectIdScalar;
6
7use super::enums::RefTypeEnum;
8use super::objects::GitObject;
9
10#[derive(SimpleObject, Clone, Debug)]
11#[graphql(complex)]
12pub struct RefObj {
13 pub name: String,
14 pub prefix: String,
15 pub short_name: String,
16 pub target_oid: ObjectIdScalar,
17 pub type_: RefTypeEnum,
18 pub is_protected: bool,
19}
20
21impl From<&RefInfo> for RefObj {
22 fn from(info: &RefInfo) -> Self {
23 let (prefix, short) = split_ref_name(&info.name);
24 RefObj {
25 name: info.name.clone(),
26 prefix,
27 short_name: short,
28 target_oid: ObjectIdScalar(info.sha),
29 type_: RefTypeEnum::from(info.ref_type),
30 is_protected: false,
31 }
32 }
33}
34
35#[ComplexObject]
36impl RefObj {
37 async fn target(&self, ctx: &async_graphql::Context<'_>) -> async_graphql::Result<GitObject> {
38 let repo = ctx.data_unchecked::<RepoCtx>();
39 resolve_git_object(repo, self.target_oid.0).await
40 }
41}
42
43pub(crate) fn split_ref_name(name: &str) -> (String, String) {
44 if let Some(rest) = name.strip_prefix("refs/heads/") {
45 ("refs/heads".to_string(), rest.to_string())
46 } else if let Some(rest) = name.strip_prefix("refs/tags/") {
47 ("refs/tags".to_string(), rest.to_string())
48 } else if let Some(rest) = name.strip_prefix("refs/remotes/") {
49 ("refs/remotes".to_string(), rest.to_string())
50 } else if let Some(rest) = name.strip_prefix("refs/") {
51 (format!("refs/{rest}"), String::new())
52 } else {
53 (String::new(), name.to_string())
54 }
55}
Deleted lib/git/src/graphql/types/repo.rs
@@ -1,49 +0,0 @@
1use async_graphql::SimpleObject;
2
3use super::commit::CommitObj;
4use super::enums::{ComparisonStatus, ObjectFormatEnum};
5
6#[derive(SimpleObject, Clone, Debug)]
7pub struct RepositoryInfoObj {
8 pub id: String,
9 pub name: String,
10 pub default_branch: String,
11 pub is_private: bool,
12 pub is_archived: bool,
13 pub enable_lfs: bool,
14 pub object_format: ObjectFormatEnum,
15 pub branches_count: i32,
16 pub tags_count: i32,
17 pub commits_count: i32,
18 pub stars_count: i32,
19 pub watchers_count: i32,
20 pub license_name: Option<String>,
21}
22
23#[derive(SimpleObject, Clone, Debug)]
24pub struct RepoStatsObj {
25 pub size: i64,
26 pub git_size: i64,
27 pub lfs_size: i64,
28 pub object_count: i64,
29 pub loose_object_count: i64,
30 pub pack_count: i64,
31}
32
33#[derive(SimpleObject, Clone, Debug)]
34pub struct PackStatsObj {
35 pub objects: i64,
36 pub size: i64,
37 pub delta_count: i64,
38}
39
40#[derive(SimpleObject, Clone, Debug)]
41pub struct ComparisonObj {
42 pub base_ref: String,
43 pub head_ref: String,
44 pub ahead: i32,
45 pub behind: i32,
46 pub status: ComparisonStatus,
47 pub num_files: i32,
48 pub commits: Vec<CommitObj>,
49}
Deleted lib/git/src/graphql/types/submodule.rs
@@ -1,22 +0,0 @@
1use async_graphql::SimpleObject;
2
3use crate::service::submodules::SubmoduleDto;
4
5#[derive(SimpleObject, Clone, Debug)]
6pub struct SubmoduleObj {
7 pub path: String,
8 pub url: String,
9 pub branch: Option<String>,
10 pub commit_sha: Option<String>,
11}
12
13impl From<&SubmoduleDto> for SubmoduleObj {
14 fn from(s: &SubmoduleDto) -> Self {
15 SubmoduleObj {
16 path: s.path.clone(),
17 url: s.url.clone(),
18 branch: s.branch.clone(),
19 commit_sha: s.commit_sha.clone(),
20 }
21 }
22}
Deleted lib/git/src/graphql/types/tag.rs
@@ -1,53 +0,0 @@
1use async_graphql::{ComplexObject, SimpleObject};
2use gitbaby::commit::types::Signature as GBSignature;
3
4use crate::graphql::query::{RepoCtx, resolve_git_object};
5use crate::graphql::scalars::ObjectIdScalar;
6
7use super::common::GitActor;
8use super::enums::TagTypeEnum;
9use super::objects::GitObject;
10
11#[derive(SimpleObject, Clone, Debug)]
12#[graphql(complex)]
13pub struct TagObj {
14 pub name: String,
15 pub oid: ObjectIdScalar,
16 pub target_oid: ObjectIdScalar,
17 pub type_: TagTypeEnum,
18 pub message: Option<String>,
19 pub tagger: Option<GitActor>,
20 pub abbreviated_oid: String,
21}
22
23pub struct TagShape {
24 pub name: String,
25 pub id: gix::hash::ObjectId,
26 pub target: gix::hash::ObjectId,
27 pub tag_type: TagTypeEnum,
28 pub message: Option<String>,
29 pub tagger: Option<GBSignature>,
30}
31
32impl From<TagShape> for TagObj {
33 fn from(t: TagShape) -> Self {
34 let hex = t.id.to_string();
35 TagObj {
36 name: t.name,
37 oid: ObjectIdScalar(t.id),
38 target_oid: ObjectIdScalar(t.target),
39 type_: t.tag_type,
40 message: t.message,
41 tagger: t.tagger.as_ref().map(GitActor::from),
42 abbreviated_oid: hex.get(..7).unwrap_or("").to_string(),
43 }
44 }
45}
46
47#[ComplexObject]
48impl TagObj {
49 async fn target(&self, ctx: &async_graphql::Context<'_>) -> async_graphql::Result<GitObject> {
50 let repo = ctx.data_unchecked::<RepoCtx>();
51 resolve_git_object(repo, self.target_oid.0).await
52 }
53}
Deleted lib/git/src/graphql/types/topic.rs
@@ -1,41 +0,0 @@
1use async_graphql::SimpleObject;
2
3use crate::graphql::scalars::DateTimeScalar;
4
5#[derive(SimpleObject, Clone, Debug)]
6pub struct TopicObj {
7 pub id: String,
8 pub name: String,
9 pub created_at: DateTimeScalar,
10 pub updated_at: DateTimeScalar,
11}
12
13impl From<models::repo::repo_topic::Model> for TopicObj {
14 fn from(m: models::repo::repo_topic::Model) -> Self {
15 Self {
16 id: m.id.to_string(),
17 name: m.name,
18 created_at: DateTimeScalar::from(m.created_at),
19 updated_at: DateTimeScalar::from(m.updated_at),
20 }
21 }
22}
23
24#[derive(SimpleObject, Clone, Debug)]
25pub struct RepoRelationTopicObj {
26 pub id: String,
27 pub repo_id: String,
28 pub topic_id: String,
29 pub created_at: DateTimeScalar,
30}
31
32impl From<models::repo::repo_relation_topic::Model> for RepoRelationTopicObj {
33 fn from(m: models::repo::repo_relation_topic::Model) -> Self {
34 Self {
35 id: m.id.to_string(),
36 repo_id: m.repo_id.to_string(),
37 topic_id: m.topic_id.to_string(),
38 created_at: DateTimeScalar::from(m.created_at),
39 }
40 }
41}
Deleted lib/git/src/graphql/types/tree.rs
@@ -1,120 +0,0 @@
1use async_graphql::{ComplexObject, SimpleObject};
2use gitbaby::commit::types::{TreeEntry, TreeEntryMode};
3use gitbaby::tree::types::TreeEntryWithLatest;
4
5use crate::graphql::error::map_git_baby;
6use crate::graphql::query::RepoCtx;
7use crate::graphql::scalars::{DateTimeScalar, ObjectIdScalar};
8
9use super::enums::TreeEntryType;
10
11#[derive(SimpleObject, Clone, Debug)]
12#[graphql(complex)]
13pub struct TreeObj {
14 pub oid: ObjectIdScalar,
15 pub entry_count: i32,
16}
17
18#[derive(SimpleObject, Clone, Debug)]
19pub struct TreeEntryObj {
20 pub name: String,
21 pub path: String,
22 pub oid: ObjectIdScalar,
23 pub mode: i32,
24 pub type_: TreeEntryType,
25 pub size: Option<i32>,
26}
27
28impl From<&TreeEntry> for TreeEntryObj {
29 fn from(e: &TreeEntry) -> Self {
30 TreeEntryObj {
31 name: e.name.clone(),
32 path: e.name.clone(),
33 oid: ObjectIdScalar(e.oid),
34 mode: mode_octal(&e.mode),
35 type_: TreeEntryType::from(e.mode),
36 size: e.size.map(|v| v as i32),
37 }
38 }
39}
40
41fn mode_octal(m: &TreeEntryMode) -> i32 {
42 match m {
43 TreeEntryMode::Blob => 0o100644,
44 TreeEntryMode::Exec => 0o100755,
45 TreeEntryMode::Tree => 0o040000,
46 TreeEntryMode::Symlink => 0o120000,
47 TreeEntryMode::Submodule => 0o160000,
48 TreeEntryMode::Commit => 0o160000,
49 }
50}
51
52#[derive(SimpleObject, Clone, Debug)]
53pub struct TreeEntryWithLatestObj {
54 pub entry: TreeEntryObj,
55 pub last_commit: String,
56 pub last_updated: DateTimeScalar,
57}
58
59impl From<&TreeEntryWithLatest> for TreeEntryWithLatestObj {
60 fn from(t: &TreeEntryWithLatest) -> Self {
61 TreeEntryWithLatestObj {
62 entry: TreeEntryObj::from(&t.entry),
63 last_commit: t.latest.id.to_string(),
64 last_updated: DateTimeScalar::from(&t.latest.author),
65 }
66 }
67}
68
69#[ComplexObject]
70impl TreeObj {
71 async fn entries(
72 &self,
73 ctx: &async_graphql::Context<'_>,
74 path: Option<String>,
75 ) -> async_graphql::Result<Vec<TreeEntryObj>> {
76 let repo = ctx.data_unchecked::<RepoCtx>();
77 let path = path.as_deref().unwrap_or("");
78 let entries = repo
79 .baby
80 .get_tree_entries(&self.oid.0.to_string(), std::path::Path::new(path))
81 .await
82 .map_err(map_git_baby)?;
83 Ok(entries.iter().map(TreeEntryObj::from).collect())
84 }
85
86 async fn walk(
87 &self,
88 ctx: &async_graphql::Context<'_>,
89 recurse: Option<bool>,
90 show_trees: Option<bool>,
91 ) -> async_graphql::Result<Vec<TreeEntryObj>> {
92 let repo = ctx.data_unchecked::<RepoCtx>();
93 let entries = repo
94 .baby
95 .walk_tree(
96 &self.oid.0.to_string(),
97 std::path::Path::new(""),
98 gitbaby::tree::types::WalkTreeOptions {
99 recurse: recurse.unwrap_or(true),
100 show_trees: show_trees.unwrap_or(false),
101 },
102 )
103 .await
104 .map_err(map_git_baby)?;
105 Ok(entries.iter().map(TreeEntryObj::from).collect())
106 }
107
108 async fn last_updated(
109 &self,
110 ctx: &async_graphql::Context<'_>,
111 ) -> async_graphql::Result<Vec<TreeEntryWithLatestObj>> {
112 let repo = ctx.data_unchecked::<RepoCtx>();
113 let rows = repo
114 .baby
115 .tree_entries_with_latest(&self.oid.0.to_string(), None)
116 .await
117 .map_err(map_git_baby)?;
118 Ok(rows.iter().map(TreeEntryWithLatestObj::from).collect())
119 }
120}
Deleted lib/git/src/graphql/error.rs
@@ -1,53 +0,0 @@
1use async_graphql::ErrorExtensions;
2
3use crate::share::errors::GitError;
4
5/// Standardized error code extension on every GraphQL error.
6fn code_for(err: &GitError) -> &'static str {
7 match err {
8 GitError::NotFound => "NOT_FOUND",
9 GitError::Unauthorized => "UNAUTHORIZED",
10 GitError::Forbidden => "FORBIDDEN",
11 GitError::Conflict(_) => "CONFLICT",
12 GitError::InvalidArgument(_) => "INVALID_ARGUMENT",
13 GitError::Cache(_) => "CACHE_ERROR",
14 GitError::Git(_) => "GIT_ERROR",
15 GitError::Internal(_) => "INTERNAL_ERROR",
16 }
17}
18
19impl ErrorExtensions for GitError {
20 fn extend(&self) -> async_graphql::Error {
21 let code = code_for(self);
22 match self {
23 GitError::Cache(_) | GitError::Git(_) | GitError::Internal(_) => {
24 tracing::error!(error = %self, "graphql internal error");
25 }
26 _ => {}
27 }
28 async_graphql::Error::new(self.to_string()).extend_with(|_, e| {
29 e.set("code", code);
30 })
31 }
32}
33
34/// Convert GitError into GraphQL error with `code` extension. Use this
35/// directly inside resolvers to keep call sites terse.
36pub fn to_gql(err: GitError) -> async_graphql::Error {
37 err.extend()
38}
39
40/// gitbaby (low-level) errors carry no GitError context; surface them as
41/// GIT_ERROR-tagged GraphQL errors.
42pub fn map_git_baby<E: std::fmt::Display>(err: E) -> async_graphql::Error {
43 tracing::error!(error = %err, "graphql gitbaby error");
44 async_graphql::Error::new(err.to_string()).extend_with(|_, e| {
45 e.set("code", "GIT_ERROR");
46 })
47}
48
49/// Stub for code paths that today must satisfy an internal type-witness
50/// (`map_bitbucket_error`) but aren't actually wired to bitbucket.
51pub fn map_bitbucket_error(err: GitError) -> GitError {
52 err
53}
Deleted lib/git/src/graphql/mod.rs
@@ -1,68 +0,0 @@
1use std::sync::Arc;
2
3use async_graphql::{EmptyMutation, EmptySubscription, Schema};
4
5use crate::service::GitService;
6
7pub mod error;
8pub mod query;
9pub mod scalars;
10pub mod types;
11
12pub use error::to_gql;
13pub use query::{GitSchema, Query, Repo};
14pub use scalars::{Base64Scalar, DateTimeScalar, ObjectIdScalar};
15
16/// Build a read-only graphql schema. The mutation and subscription
17/// types are intentionally empty — gitdata exposes only query.
18pub fn build_schema(state: export::AppState) -> GitSchema {
19 let introspection_enabled = state.config.git.graphql_introspection;
20 let mut builder = Schema::build(Query, EmptyMutation, EmptySubscription)
21 .data(Arc::new(GitService::new(state.clone())))
22 .limit_complexity(state.config.git.graphql_complexity_limit.max(1) as usize)
23 .limit_depth(15);
24 if !introspection_enabled {
25 builder = builder.disable_introspection();
26 }
27 builder.finish()
28}
29
30#[cfg(test)]
31mod tests {
32 use super::*;
33
34 fn schema_with_default_state() -> GitSchema {
35 // The schema's SDL shape is independent of the embedded
36 // `GitService` — data is only consulted at request time. Build a
37 // schema without data; no requests are executed in these tests.
38 Schema::build(Query, EmptyMutation, EmptySubscription).finish()
39 }
40
41 #[test]
42 fn schema_contains_root_and_key_types() {
43 let sdl = schema_with_default_state().sdl();
44 // Print SDL for diagnostic if any assertion fails.
45 eprintln!("--- SDL ---\n{sdl}\n---");
46 assert!(sdl.contains("type Query"), "Query root missing");
47 assert!(sdl.contains("repo("), "repo entry-point missing");
48 assert!(sdl.contains("type Commit"), "Commit object missing");
49 assert!(sdl.contains("type Blob"), "Blob object missing");
50 assert!(sdl.contains("type Tree"), "Tree object missing");
51 assert!(sdl.contains("type Tag"), "Tag object missing");
52 assert!(sdl.contains("type Ref"), "Ref object missing");
53 assert!(sdl.contains("type Blame"), "Blame object missing");
54 assert!(sdl.contains("type Submodule"), "Submodule object missing");
55 assert!(sdl.contains("type LanguageStat"), "LanguageStats missing");
56 assert!(sdl.contains("type CommitStatus"), "CommitStatus missing");
57 assert!(sdl.contains("scalar ObjectId"), "ObjectId scalar missing");
58 assert!(sdl.contains("scalar DateTime"), "DateTime scalar missing");
59 assert!(sdl.contains("union GitObject"), "GitObject union missing");
60 assert!(sdl.contains("input PageInput"), "PageInput missing");
61 assert!(
62 sdl.contains("input LogOptionsInput"),
63 "LogOptionsInput missing"
64 );
65 // read-only: schema should not declare user-facing Mutation fields
66 assert!(!sdl.contains("type Mutation"), "Mutation should be empty");
67 }
68}
Deleted lib/git/src/graphql/scalars.rs
@@ -1,145 +0,0 @@
1use async_graphql::{InputValueError, InputValueResult, Scalar, ScalarType, Value};
2use base64::Engine;
3use base64::engine::general_purpose::STANDARD as BASE64_ENGINE;
4
5/// Hex-encoded git object id (sha1/sha256).
6#[derive(Clone, Copy, Debug)]
7pub struct ObjectIdScalar(pub gix::hash::ObjectId);
8
9#[Scalar(
10 name = "ObjectId",
11 specified_by_url = "https://git-scm.com/docs/gitglossary#def_object-id"
12)]
13impl ScalarType for ObjectIdScalar {
14 fn parse(value: Value) -> InputValueResult<Self> {
15 if let Value::String(s) = value {
16 let oid = gix::hash::ObjectId::from_hex(s.as_bytes())
17 .map_err(|e| InputValueError::custom(format!("invalid object id: {e}")))?;
18 return Ok(ObjectIdScalar(oid));
19 }
20 Err(InputValueError::custom("ObjectId must be a hex string"))
21 }
22
23 fn to_value(&self) -> Value {
24 Value::String(self.0.to_string())
25 }
26}
27
28impl From<gix::hash::ObjectId> for ObjectIdScalar {
29 fn from(id: gix::hash::ObjectId) -> Self {
30 ObjectIdScalar(id)
31 }
32}
33
34impl From<ObjectIdScalar> for gix::hash::ObjectId {
35 fn from(s: ObjectIdScalar) -> Self {
36 s.0
37 }
38}
39
40/// Date + offset in seconds (sign matters).
41#[derive(Clone, Copy, Debug)]
42pub struct DateTimeScalar {
43 pub timestamp: i64,
44 pub offset_seconds: i32,
45}
46
47#[Scalar(
48 name = "DateTime",
49 specified_by_url = "https://git-scm.com/docs/git-commit#_date_formats"
50)]
51impl ScalarType for DateTimeScalar {
52 fn parse(value: Value) -> InputValueResult<Self> {
53 if let Value::Object(mut obj) = value {
54 let ts = obj
55 .swap_remove("timestamp")
56 .ok_or_else(|| InputValueError::custom("DateTime: `timestamp` is required"))?;
57 let off = obj
58 .swap_remove("offsetSeconds")
59 .ok_or_else(|| InputValueError::custom("DateTime: `offsetSeconds` is required"))?;
60 let ts = match ts {
61 Value::Number(n) => n
62 .as_i64()
63 .ok_or_else(|| InputValueError::custom("DateTime: `timestamp` must be int"))?,
64 _ => {
65 return Err(InputValueError::custom(
66 "DateTime: `timestamp` must be a number",
67 ));
68 }
69 };
70 let off = match off {
71 Value::Number(n) => n.as_i64().ok_or_else(|| {
72 InputValueError::custom("DateTime: `offsetSeconds` must be int")
73 })? as i32,
74 _ => {
75 return Err(InputValueError::custom(
76 "DateTime: `offsetSeconds` must be a number",
77 ));
78 }
79 };
80 return Ok(DateTimeScalar {
81 timestamp: ts,
82 offset_seconds: off,
83 });
84 }
85 Err(InputValueError::custom(
86 "DateTime must be an object with `timestamp` and `offsetSeconds`",
87 ))
88 }
89
90 fn to_value(&self) -> Value {
91 let mut obj = async_graphql::indexmap::IndexMap::new();
92 obj.insert(
93 async_graphql::Name::new("timestamp"),
94 Value::Number(self.timestamp.into()),
95 );
96 obj.insert(
97 async_graphql::Name::new("offsetSeconds"),
98 Value::Number((self.offset_seconds as i64).into()),
99 );
100 Value::Object(obj)
101 }
102}
103
104impl From<&gitbaby::commit::types::Signature> for DateTimeScalar {
105 fn from(sig: &gitbaby::commit::types::Signature) -> Self {
106 let ts = sig.time.unix_timestamp();
107 DateTimeScalar {
108 timestamp: ts,
109 offset_seconds: sig.time.offset().whole_seconds(),
110 }
111 }
112}
113
114impl From<time::OffsetDateTime> for DateTimeScalar {
115 fn from(dt: time::OffsetDateTime) -> Self {
116 DateTimeScalar {
117 timestamp: dt.unix_timestamp(),
118 offset_seconds: dt.offset().whole_seconds(),
119 }
120 }
121}
122
123/// Bytes ↔ base64 string for blob payloads.
124#[derive(Clone, Debug)]
125pub struct Base64Scalar(pub Vec<u8>);
126
127#[Scalar(
128 name = "Base64",
129 specified_by_url = "https://www.rfc-editor.org/rfc/rfc4648"
130)]
131impl ScalarType for Base64Scalar {
132 fn parse(value: Value) -> InputValueResult<Self> {
133 if let Value::String(s) = value {
134 let bytes = BASE64_ENGINE
135 .decode(s.as_bytes())
136 .map_err(|e| InputValueError::custom(format!("invalid base64: {e}")))?;
137 return Ok(Base64Scalar(bytes));
138 }
139 Err(InputValueError::custom("Base64 must be a string"))
140 }
141
142 fn to_value(&self) -> Value {
143 Value::String(BASE64_ENGINE.encode(&self.0))
144 }
145}
Modified lib/git/src/handler/archives/create.rs View lib/git/src/handler/archives/create.rs
@@ -17,13 +17,13 @@
1717 Zip,
1818}
1919
20impl From<ArchiveFormatDto> for gitbaby::archive::types::ArchiveFormat {
20impl From<ArchiveFormatDto> for crate::rpcgit::archive::types::ArchiveFormat {
2121 fn from(v: ArchiveFormatDto) -> Self {
2222 match v {
23 ArchiveFormatDto::Tar => gitbaby::archive::types::ArchiveFormat::Tar,
24 ArchiveFormatDto::TarGz => gitbaby::archive::types::ArchiveFormat::TarGz,
25 ArchiveFormatDto::TarBz2 => gitbaby::archive::types::ArchiveFormat::TarBz2,
26 ArchiveFormatDto::Zip => gitbaby::archive::types::ArchiveFormat::Zip,
23 ArchiveFormatDto::Tar => crate::rpcgit::archive::types::ArchiveFormat::Tar,
24 ArchiveFormatDto::TarGz => crate::rpcgit::archive::types::ArchiveFormat::TarGz,
25 ArchiveFormatDto::TarBz2 => crate::rpcgit::archive::types::ArchiveFormat::TarBz2,
26 ArchiveFormatDto::Zip => crate::rpcgit::archive::types::ArchiveFormat::Zip,
2727 }
2828 }
2929}
Modified lib/git/src/handler/commits/get.rs View lib/git/src/handler/commits/get.rs
@@ -1,6 +1,6 @@
1use crate::rpcgit::commit::types::CommitInfo;
12use axum::extract::{Path, State};
23use export::rest::AppResponseData;
3use gitbaby::commit::types::CommitInfo;
44use jwt::Session;
55
66use crate::errors::{ApiError, GitApiError};
Modified lib/git/src/handler/commits/is_ancestor.rs View lib/git/src/handler/commits/is_ancestor.rs
@@ -1,7 +1,7 @@
11use axum::extract::{Path, Query, State};
22use export::rest::AppResponseData;
3use jwt::Session;
34use serde::Serialize;
4use jwt::Session;
55
66use crate::errors::{ApiError, GitApiError};
77use crate::handler::resolve_reader;
Modified lib/git/src/handler/commits/stats.rs View lib/git/src/handler/commits/stats.rs
@@ -1,8 +1,8 @@
11use std::path::PathBuf;
22
3use crate::rpcgit::commit::types::CommitStats;
34use axum::extract::{Path, Query, State};
45use export::rest::AppResponseData;
5use gitbaby::commit::types::CommitStats;
66use jwt::Session;
77
88use crate::errors::{ApiError, GitApiError};
Modified lib/git/src/handler/commits/sync_commit.rs View lib/git/src/handler/commits/sync_commit.rs
@@ -1,7 +1,7 @@
11use axum::extract::{Path, State};
22use export::rest::AppResponseData;
3use jwt::Session;
34use serde::Serialize;
4use jwt::Session;
55
66use crate::errors::{ApiError, GitApiError};
77use crate::handler::require_writer;
Modified lib/git/src/handler/diffs/for_commit.rs View lib/git/src/handler/diffs/for_commit.rs
@@ -1,6 +1,6 @@
1use crate::rpcgit::diff::types::ChangedPath;
12use axum::extract::{Path, State};
23use export::rest::AppResponseData;
3use gitbaby::diff::types::ChangedPath;
44use jwt::Session;
55
66use crate::errors::{ApiError, GitApiError};
Modified lib/git/src/handler/diffs/get_diff.rs View lib/git/src/handler/diffs/get_diff.rs
@@ -1,7 +1,7 @@
11use axum::extract::{Path, Query, State};
22use export::rest::AppResponseData;
3use jwt::Session;
34use serde::Deserialize;
4use jwt::Session;
55
66use crate::errors::{ApiError, GitApiError};
77use crate::handler::resolve_reader;
Modified lib/git/src/handler/diffs/range_diff.rs View lib/git/src/handler/diffs/range_diff.rs
@@ -1,6 +1,6 @@
1use crate::rpcgit::diff::types::RangeDiffSpec;
12use axum::extract::{Path, Query, State};
23use export::rest::AppResponseData;
3use gitbaby::diff::types::RangeDiffSpec;
44use jwt::Session;
55
66use crate::errors::{ApiError, GitApiError};
Modified lib/git/src/handler/html/blame.rs View lib/git/src/handler/html/blame.rs
@@ -1,8 +1,8 @@
11use std::path::PathBuf;
22
3use crate::rpcgit::commit::types::TreeEntryMode;
34use axum::extract::{Path, Query, State};
45use axum::response::Response;
5use gitbaby::commit::types::TreeEntryMode;
66use maud::html;
77
88use crate::handler::html::error::html_err;
Modified lib/git/src/handler/html/blob_content.rs View lib/git/src/handler/html/blob_content.rs
@@ -1,6 +1,6 @@
1use crate::rpcgit::commit::types::TreeEntryMode;
12use axum::extract::{Path, Query, State};
23use axum::response::Response;
3use gitbaby::commit::types::TreeEntryMode;
44use maud::html;
55
66use crate::handler::html::error::html_err;
Modified lib/git/src/handler/html/last_commit.rs View lib/git/src/handler/html/last_commit.rs
@@ -1,6 +1,6 @@
1use crate::rpcgit::tree::types::TreeEntryWithLatest;
12use axum::extract::{Path, Query, State};
23use axum::response::Response;
3use gitbaby::tree::types::TreeEntryWithLatest;
44use maud::Markup;
55use maud::html;
66
Modified lib/git/src/handler/html/readme.rs View lib/git/src/handler/html/readme.rs
@@ -1,6 +1,6 @@
1use crate::rpcgit::commit::types::TreeEntryMode;
12use axum::extract::{Path, Query, State};
23use axum::response::Response;
3use gitbaby::commit::types::TreeEntryMode;
44use maud::{PreEscaped, html};
55
66use crate::handler::html::error::html_err;
Modified lib/git/src/handler/html/tree.rs View lib/git/src/handler/html/tree.rs
@@ -1,7 +1,7 @@
1use crate::rpcgit::commit::types::TreeEntryMode;
2use crate::rpcgit::tree::types::TreeEntryWithLatest;
13use axum::extract::{Path, Query, State};
24use axum::response::Response;
3use gitbaby::commit::types::TreeEntryMode;
4use gitbaby::tree::types::TreeEntryWithLatest;
55use maud::Markup;
66use maud::html;
77
Modified lib/git/src/handler/html/tree_row.rs View lib/git/src/handler/html/tree_row.rs
@@ -1,6 +1,6 @@
1use crate::rpcgit::commit::types::TreeEntryMode;
12use axum::extract::{Path, Query, State};
23use axum::response::Response;
3use gitbaby::commit::types::TreeEntryMode;
44use maud::html;
55
66use crate::handler::html::error::html_err;
Modified lib/git/src/handler/lfs/mod.rs View lib/git/src/handler/lfs/mod.rs
@@ -1,5 +1,4 @@
11use jwt::Session;
2use uuid::Uuid;
32
43use crate::errors::ApiError;
54use crate::handler::{require_repo_admin, require_writer};
Modified lib/git/src/handler/refs/filtered.rs View lib/git/src/handler/refs/filtered.rs
@@ -1,6 +1,6 @@
1use crate::rpcgit::refs::types::RefInfo;
12use axum::extract::{Path, Query, State};
23use export::rest::AppResponseData;
3use gitbaby::refs::types::RefInfo;
44use jwt::Session;
55
66use crate::errors::{ApiError, GitApiError};
Modified lib/git/src/handler/refs/get.rs View lib/git/src/handler/refs/get.rs
@@ -1,6 +1,6 @@
1use crate::rpcgit::refs::types::RefInfo;
12use axum::extract::{Path, State};
23use export::rest::AppResponseData;
3use gitbaby::refs::types::RefInfo;
44use jwt::Session;
55
66use crate::errors::{ApiError, GitApiError};
Modified lib/git/src/handler/refs/is_object_in_ref.rs View lib/git/src/handler/refs/is_object_in_ref.rs
@@ -1,7 +1,7 @@
11use axum::extract::{Path, Query, State};
22use export::rest::AppResponseData;
3use jwt::Session;
34use serde::Serialize;
4use jwt::Session;
55
66use crate::errors::{ApiError, GitApiError};
77use crate::handler::resolve_reader;
Modified lib/git/src/handler/refs/list.rs View lib/git/src/handler/refs/list.rs
@@ -1,6 +1,6 @@
1use crate::rpcgit::refs::types::RefInfo;
12use axum::extract::{Path, State};
23use export::rest::AppResponseData;
3use gitbaby::refs::types::RefInfo;
44use jwt::Session;
55
66use crate::errors::{ApiError, GitApiError};
Modified lib/git/src/handler/refs/update.rs View lib/git/src/handler/refs/update.rs
@@ -1,8 +1,8 @@
11use axum::Json;
22use axum::extract::{Path, State};
33use export::rest::AppResponseData;
4use jwt::Session;
45use serde::{Deserialize, Serialize};
5use jwt::Session;
66
77use crate::errors::{ApiError, GitApiError};
88use crate::handler::require_writer;
Modified lib/git/src/handler/repos/create.rs View lib/git/src/handler/repos/create.rs
@@ -2,7 +2,6 @@
22use axum::extract::{Path, State};
33use export::rest::AppResponseData;
44use jwt::Session;
5use uuid::Uuid;
65
76use crate::errors::{ApiError, GitApiError};
87use crate::handler::ensure_caller;
Modified lib/git/src/handler/repos/list.rs View lib/git/src/handler/repos/list.rs
@@ -1,6 +1,6 @@
11use axum::extract::{Path, Query, State};
22use export::rest::AppResponseData;
3use jwt::{Session, SessionUser};
3use jwt::Session;
44
55use crate::errors::{ApiError, GitApiError};
66use crate::service::GitService;
Modified lib/git/src/handler/trees/read.rs View lib/git/src/handler/trees/read.rs
@@ -2,8 +2,8 @@
22
33use axum::extract::{Path, Query, State};
44use export::rest::AppResponseData;
5use jwt::Session;
56use serde::Deserialize;
6use jwt::Session;
77
88use crate::errors::{ApiError, GitApiError};
99use crate::handler::resolve_reader;
Deleted lib/git/src/handler/graphql.rs
@@ -1,48 +0,0 @@
1use axum::Json;
2use axum::extract::State;
3use axum::response::IntoResponse;
4use jwt::{Session, SessionUser};
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::graphql::GitSchema;
9use crate::service::GitService;
10
11#[utoipa::path(
12 post,
13 path = "/graphql",
14 tag = "graphql",
15 description = "执行仓库只读 GraphQL 查询,并解析器内施加仓库读权限。",
16 request_body = String,
17 responses(
18 (status = 200, description = "graphql execution result")
19 )
20)]
21pub async fn graphql_post(
22 State(_service): State<GitService>,
23 session: Session,
24 schema: axum::extract::Extension<GitSchema>,
25 Json(mut request): Json<async_graphql::Request>,
26) -> Result<axum::response::Response, ApiError> {
27 request = request.data::<Option<i64>>(session.uid());
28 let response = schema.execute(request).await;
29 Ok(Json(response).into_response())
30}
31
32#[utoipa::path(
33 get,
34 path = "/graphql",
35 tag = "graphql",
36 description = "通过固定内省查询探活 GraphQL 服务。",
37 responses(
38 (status = 200, description = "graphql execution result")
39 )
40)]
41pub async fn graphql_get(
42 State(_service): State<GitService>,
43 schema: axum::extract::Extension<GitSchema>,
44) -> Result<axum::response::Response, ApiError> {
45 let request = async_graphql::Request::new("{ __typename }");
46 let response = schema.execute(request).await;
47 Ok(Json(response).into_response())
48}
Modified lib/git/src/handler/mod.rs View lib/git/src/handler/mod.rs
@@ -9,7 +9,6 @@
99pub mod conflicts;
1010pub mod content;
1111pub mod diffs;
12pub mod graphql;
1312pub mod hooks;
1413pub mod html;
1514pub mod lfs;
Modified lib/git/src/handler/share.rs View lib/git/src/handler/share.rs
@@ -1,9 +1,8 @@
11use axum::extract::{FromRequestParts, Path};
22use axum::http::request::Parts;
3use jwt::Session;
34use serde::Deserialize;
4use jwt::{Session, SessionUser};
55use utoipa::IntoParams;
6use uuid::Uuid;
76
87use crate::errors::ApiError;
98use crate::service::GitService;
Modified lib/git/src/handler/stars.rs View lib/git/src/handler/stars.rs
@@ -1,7 +1,7 @@
11use axum::extract::{Path, Query, State};
22use export::rest::AppResponseData;
3use jwt::Session;
34use serde::Deserialize;
4use jwt::Session;
55use utoipa::IntoParams;
66
77use crate::errors::{ApiError, GitApiError};
Modified lib/git/src/handler/topics.rs View lib/git/src/handler/topics.rs
@@ -1,8 +1,8 @@
11use axum::Json;
22use axum::extract::{Path, Query, State};
33use export::rest::{AppResponseData, NoData};
4use jwt::Session;
45use serde::{Deserialize, Serialize};
5use jwt::Session;
66use utoipa::IntoParams;
77
88use crate::errors::{ApiError, GitApiError};
Modified lib/git/src/handler/watches.rs View lib/git/src/handler/watches.rs
@@ -1,9 +1,9 @@
11use axum::Json;
22use axum::extract::{Path, Query, State};
33use export::rest::AppResponseData;
4use jwt::Session;
45use models::common::watch_mode::WatchMode;
56use serde::Deserialize;
6use jwt::Session;
77use utoipa::IntoParams;
88
99use crate::errors::{ApiError, GitApiError};
Modified lib/git/src/lfs/error.rs View lib/git/src/lfs/error.rs
@@ -7,6 +7,7 @@
77 GitError::Forbidden => 403,
88 GitError::Conflict(_) => 409,
99 GitError::InvalidArgument(_) => 400,
10 GitError::Unimplemented(_) => 501,
1011 GitError::Cache(_) | GitError::Git(_) | GitError::Internal(_) => 500,
1112 }
1213}
Deleted lib/git/src/lfs/handle.rs
@@ -1,407 +0,0 @@
1use axum::Router;
2use axum::body::Body;
3use axum::extract::{Path, Query, State};
4use axum::http::{HeaderMap, StatusCode, header};
5use axum::response::{IntoResponse, Json, Response};
6use axum::routing::{get, post, put};
7use futures::StreamExt as _;
8use http_body_util::BodyDataStream;
9use serde::{Deserialize, Serialize};
10use tokio_util::io::StreamReader;
11use uuid::Uuid;
12
13use crate::lfs::types::{
14 LfsBatchRequest, LfsBatchResponse, LfsLockCreateRequest, LfsLockListResponse, LfsOperation,
15 LfsVerifyLocksRequest, LfsVerifyLocksResponse,
16};
17use crate::lfs::{error, object};
18use crate::service::GitService;
19use crate::share::access::{BasicAuth, allow_repo_read, require_repo_admin, require_writer};
20use crate::share::errors::GitError;
21
22const DOCUMENTATION_URL: &str = "https://github.com/git-lfs/git-lfs/blob/main/docs/api/errors.md";
23
24pub fn router(service: GitService) -> Router<GitService> {
25 Router::new()
26 .route("/info/lfs", get(discovery))
27 .route("/info/lfs/objects/batch", post(batch))
28 .route("/info/lfs/objects/verify", post(verify_object))
29 .route(
30 "/info/lfs/objects/{oid}",
31 put(upload_object).get(download_object),
32 )
33 .route("/info/lfs/locks", get(list_locks).post(create_lock))
34 .route("/info/lfs/locks/verify", post(verify_locks))
35 .route("/info/lfs/locks/{lock_id}/unlock", post(unlock_lock))
36 .with_state(service)
37}
38
39pub async fn discovery(
40 State(svc): State<GitService>,
41 Path((space, repo_slug)): Path<(String, String)>,
42) -> Result<Json<Discovery>, LfsHttpError> {
43 let repo = skinny_repo_name(&repo_slug);
44 let base = svc
45 .state
46 .config
47 .web
48 .web_base_url
49 .trim_end_matches('/')
50 .to_string();
51 let href = if base.is_empty() {
52 format!("/{space}/{repo}.git/info/lfs")
53 } else {
54 format!("{base}/{space}/{repo}.git/info/lfs")
55 };
56 Ok(Json(Discovery { href }))
57}
58
59#[derive(Debug, Serialize)]
60pub struct Discovery {
61 pub href: String,
62}
63
64pub async fn batch(
65 State(svc): State<GitService>,
66 auth: BasicAuth,
67 Path((space, repo_slug)): Path<(String, String)>,
68 Json(req): Json<LfsBatchRequest>,
69) -> Result<Json<LfsBatchResponse>, LfsHttpError> {
70 let repo = skinny_repo_name(&repo_slug);
71 let ctx = svc
72 .context(space.clone(), repo.to_string())
73 .await
74 .map_err(LfsHttpError::from)?;
75 let needs_write = matches!(req.operation, LfsOperation::Upload | LfsOperation::Verify);
76 if needs_write {
77 require_writer(&svc, &auth, &ctx)
78 .await
79 .map_err(LfsHttpError::from)?;
80 } else {
81 allow_repo_read(&svc, &auth, &ctx)
82 .await
83 .map_err(LfsHttpError::from)?;
84 }
85 let resp = object::batch(&svc, space, repo.to_string(), req)
86 .await
87 .map_err(LfsHttpError::from)?;
88 Ok(Json(resp))
89}
90
91pub async fn upload_object(
92 State(svc): State<GitService>,
93 auth: BasicAuth,
94 Path((space, repo_slug, oid)): Path<(String, String, String)>,
95 headers: HeaderMap,
96 body: Body,
97) -> Result<StatusCode, LfsHttpError> {
98 let repo = skinny_repo_name(&repo_slug);
99 let ctx = svc
100 .context(space.clone(), repo.to_string())
101 .await
102 .map_err(LfsHttpError::from)?;
103 let uid = require_writer(&svc, &auth, &ctx)
104 .await
105 .map_err(LfsHttpError::from)?;
106 let size = content_length(&headers).map_err(LfsHttpError::bad_request)?;
107 let content_type = headers
108 .get(header::CONTENT_TYPE)
109 .and_then(|v| v.to_str().ok())
110 .map(str::to_string);
111 let stream = BodyDataStream::new(body).map(|chunk| match chunk {
112 Ok(bytes) => Ok(bytes),
113 Err(e) => Err(std::io::Error::other(e.to_string())),
114 });
115 let reader = StreamReader::new(stream);
116 object::upload_stream(
117 &svc,
118 space,
119 repo.to_string(),
120 uid,
121 oid.clone(),
122 size,
123 content_type,
124 reader,
125 )
126 .await
127 .map_err(LfsHttpError::from)?;
128 Ok(StatusCode::OK)
129}
130
131pub async fn download_object(
132 State(svc): State<GitService>,
133 auth: BasicAuth,
134 Path((space, repo_slug, oid)): Path<(String, String, String)>,
135) -> Result<Response, LfsHttpError> {
136 let repo = skinny_repo_name(&repo_slug);
137 let ctx = svc
138 .context(space.clone(), repo.to_string())
139 .await
140 .map_err(LfsHttpError::from)?;
141 allow_repo_read(&svc, &auth, &ctx)
142 .await
143 .map_err(LfsHttpError::from)?;
144 let oid = crate::lfs::canonical_oid(&oid)?;
145 let key = format!("lfs/{}/{}", ctx.model.id.simple(), oid);
146 let storage = &svc.state.storage;
147 let stream = storage
148 .client()
149 .get_stream(&key)
150 .await
151 .map_err(|e| LfsHttpError::from(GitError::Internal(format!("lfs get: {e}"))))?;
152 let body = Body::from_stream(
153 stream.map(|chunk| chunk.map_err(|e| -> axum::Error { axum::Error::new(e.to_string()) })),
154 );
155 Ok((StatusCode::OK, body).into_response())
156}
157
158pub async fn verify_object(
159 State(svc): State<GitService>,
160 auth: BasicAuth,
161 Path((space, repo_slug)): Path<(String, String)>,
162 Json(req): Json<crate::lfs::types::LfsVerifyRequest>,
163) -> Result<StatusCode, LfsHttpError> {
164 let repo = skinny_repo_name(&repo_slug);
165 let ctx = svc
166 .context(space.clone(), repo.to_string())
167 .await
168 .map_err(LfsHttpError::from)?;
169 require_writer(&svc, &auth, &ctx)
170 .await
171 .map_err(LfsHttpError::from)?;
172 object::verify(&svc, space, repo.to_string(), req)
173 .await
174 .map_err(LfsHttpError::from)?;
175 Ok(StatusCode::OK)
176}
177
178pub async fn list_locks(
179 State(svc): State<GitService>,
180 auth: BasicAuth,
181 Path((space, repo_slug)): Path<(String, String)>,
182 Query(q): Query<ListLocksQuery>,
183) -> Result<Json<LfsLockListResponse>, LfsHttpError> {
184 let repo = skinny_repo_name(&repo_slug);
185 let ctx = svc
186 .context(space.clone(), repo.to_string())
187 .await
188 .map_err(LfsHttpError::from)?;
189 allow_repo_read(&svc, &auth, &ctx)
190 .await
191 .map_err(LfsHttpError::from)?;
192 let resp = crate::lfs::list_locks(&svc, space, repo.to_string(), q.cursor, q.limit)
193 .await
194 .map_err(LfsHttpError::from)?;
195 Ok(Json(resp))
196}
197
198pub async fn create_lock(
199 State(svc): State<GitService>,
200 auth: BasicAuth,
201 Path((space, repo_slug)): Path<(String, String)>,
202 Json(req): Json<LfsLockCreateRequest>,
203) -> Result<(StatusCode, Json<LockResponse>), LfsHttpError> {
204 let repo = skinny_repo_name(&repo_slug);
205 let ctx = svc
206 .context(space.clone(), repo.to_string())
207 .await
208 .map_err(LfsHttpError::from)?;
209 let uid = require_writer(&svc, &auth, &ctx)
210 .await
211 .map_err(LfsHttpError::from)?;
212 let lock = crate::lfs::create_lock(&svc, space, repo.to_string(), uid, req.path, None)
213 .await
214 .map_err(LfsHttpError::from)?;
215 Ok((StatusCode::CREATED, Json(LockResponse { lock })))
216}
217
218#[derive(Debug, Serialize)]
219pub struct LockResponse {
220 lock: crate::lfs::types::LfsLock,
221}
222
223pub async fn verify_locks(
224 State(svc): State<GitService>,
225 auth: BasicAuth,
226 Path((space, repo_slug)): Path<(String, String)>,
227 Json(req): Json<LfsVerifyLocksRequest>,
228) -> Result<Json<LfsVerifyLocksResponse>, LfsHttpError> {
229 let ctx = svc
230 .context(space.clone(), skinny_repo_name(&repo_slug).to_string())
231 .await
232 .map_err(LfsHttpError::from)?;
233 let uid = match allow_repo_read(&svc, &auth, &ctx)
234 .await
235 .map_err(LfsHttpError::from)?
236 {
237 Some(uid) => uid,
238 None => {
239 return Err(LfsHttpError::unauthorized());
240 }
241 };
242 let resp = crate::lfs::verify_locks(
243 &svc,
244 space,
245 skinny_repo_name(&repo_slug).to_string(),
246 uid,
247 req.limit.unwrap_or(100) as u64,
248 )
249 .await
250 .map_err(LfsHttpError::from)?;
251 Ok(Json(resp))
252}
253
254pub async fn unlock_lock(
255 State(svc): State<GitService>,
256 auth: BasicAuth,
257 Path((space, repo_slug, lock_id)): Path<(String, String, String)>,
258 Query(q): Query<UnlockQuery>,
259) -> Result<Json<UnlockResponse>, LfsHttpError> {
260 let repo = skinny_repo_name(&repo_slug);
261 let ctx = svc
262 .context(space.clone(), repo.to_string())
263 .await
264 .map_err(LfsHttpError::from)?;
265 // `force=true` (per LFS spec) is reserved for repo admins — must be the
266 // space owner or an Admin collaborator, not an arbitrary Write token.
267 let uid = if q.force {
268 require_repo_admin(&svc, &auth, &ctx)
269 .await
270 .map_err(LfsHttpError::from)?
271 } else {
272 require_writer(&svc, &auth, &ctx)
273 .await
274 .map_err(LfsHttpError::from)?
275 };
276 crate::lfs::unlock_by_id(&svc, space, repo.to_string(), uid, lock_id, q.force)
277 .await
278 .map_err(LfsHttpError::from)?;
279 Ok(Json(UnlockResponse {
280 message: "unlock successful".to_string(),
281 }))
282}
283
284#[derive(Debug, Serialize)]
285pub struct UnlockResponse {
286 pub message: String,
287}
288
289#[derive(Debug, Deserialize)]
290pub struct ListLocksQuery {
291 pub cursor: Option<String>,
292 pub limit: u64,
293}
294
295#[derive(Debug, Deserialize)]
296pub struct UnlockQuery {
297 #[serde(default)]
298 pub force: bool,
299}
300
301fn skinny_repo_name(slug: &str) -> &str {
302 slug.strip_suffix(".git").unwrap_or(slug)
303}
304
305fn content_length(headers: &HeaderMap) -> Result<i64, &'static str> {
306 let raw = headers
307 .get(header::CONTENT_LENGTH)
308 .ok_or("missing Content-Length header")?;
309 raw.to_str()
310 .ok()
311 .and_then(|s| s.parse::<i64>().ok())
312 .ok_or("invalid Content-Length header")
313}
314
315#[derive(Debug, Serialize)]
316struct LfsErrorBody {
317 message: String,
318 request_id: String,
319 documentation_url: &'static str,
320}
321
322fn status_json(status: StatusCode, message: &str) -> Response {
323 (
324 status,
325 Json(LfsErrorBody {
326 message: message.to_string(),
327 request_id: Uuid::now_v7().simple().to_string(),
328 documentation_url: DOCUMENTATION_URL,
329 }),
330 )
331 .into_response()
332}
333
334fn challenge() -> Response {
335 (
336 StatusCode::UNAUTHORIZED,
337 [(header::WWW_AUTHENTICATE, "Basic realm=\"gitdata\"")],
338 Json(LfsErrorBody {
339 message: "authentication required".to_string(),
340 request_id: Uuid::now_v7().simple().to_string(),
341 documentation_url: DOCUMENTATION_URL,
342 }),
343 )
344 .into_response()
345}
346
347#[derive(Debug)]
348/// Boxed: `Response<Body>` is 128 bytes, which would otherwise ride along in
349/// every `Result` these LFS handlers return.
350pub struct LfsHttpError {
351 response: Box<Response>,
352}
353
354impl LfsHttpError {
355 pub fn unauthorized() -> Self {
356 Self {
357 response: Box::new(challenge()),
358 }
359 }
360
361 fn bad_request(message: &str) -> Self {
362 Self {
363 response: Box::new(status_json(StatusCode::BAD_REQUEST, message)),
364 }
365 }
366}
367
368impl IntoResponse for LfsHttpError {
369 fn into_response(self) -> Response {
370 *self.response
371 }
372}
373
374impl From<GitError> for LfsHttpError {
375 fn from(err: GitError) -> Self {
376 let status = StatusCode::from_u16(error::http_status(&err))
377 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
378 let message = match &err {
379 GitError::Unauthorized => "authentication required".to_string(),
380 GitError::Cache(_) | GitError::Git(_) | GitError::Internal(_) => {
381 tracing::error!(error = %err, "lfs internal error");
382 "internal server error".to_string()
383 }
384 _ => err.to_string(),
385 };
386 if status == StatusCode::UNAUTHORIZED {
387 Self {
388 response: Box::new(
389 (
390 StatusCode::UNAUTHORIZED,
391 [(header::WWW_AUTHENTICATE, "Basic realm=\"gitdata\"")],
392 Json(LfsErrorBody {
393 message,
394 request_id: Uuid::now_v7().simple().to_string(),
395 documentation_url: DOCUMENTATION_URL,
396 }),
397 )
398 .into_response(),
399 ),
400 }
401 } else {
402 Self {
403 response: Box::new(status_json(status, &message)),
404 }
405 }
406 }
407}
Modified lib/git/src/lfs/mod.rs View lib/git/src/lfs/mod.rs
@@ -10,18 +10,11 @@
1010//! `service/lfs.rs` delegate here and stay the stable public entry point.
1111
1212pub mod error;
13pub mod handle;
1413pub mod lock;
15pub mod object;
1614pub mod types;
1715pub mod validate;
1816
1917pub use error::GitError as LfsErrorKind;
20pub use handle::router as lfs_router;
2118pub use lock::{create as create_lock, list as list_locks, unlock, unlock_by_id, verify_locks};
22pub use object::{
23 batch, cleanup_unreferenced, download, download_to, upload, upload_stream,
24 verify as verify_object,
25};
2619pub use types::*;
2720pub use validate::{canonical_oid, is_canonical_oid, validate_lock_path, validate_size};
Deleted lib/git/src/lfs/object.rs
@@ -1,503 +0,0 @@
1use std::collections::HashMap;
2use std::pin::Pin;
3use std::task::Poll;
4
5use export::storage::AppStorage;
6use models::git::git_lfs;
7use models::repo::repos;
8use sea_orm::sea_query::Expr;
9use sea_orm::{ColumnTrait, EntityTrait, ExprTrait, QueryFilter, Set};
10use sha2::{Digest, Sha256};
11use time::OffsetDateTime;
12use uuid::Uuid;
13
14use crate::lfs::types::{
15 LfsAction, LfsBatchObject, LfsBatchRequest, LfsBatchResponse, LfsError, LfsOperation,
16 LfsVerifyRequest,
17};
18use crate::lfs::validate::{canonical_oid, validate_size};
19use crate::service::GitService;
20use crate::share::errors::GitError;
21
22pub const UPLOAD_TTL_SECS: u32 = 3600;
23pub const DOWNLOAD_TTL_SECS: u32 = 3600;
24pub const MAX_BATCH_OBJECTS: usize = 200;
25
26pub async fn batch(
27 svc: &GitService,
28 space_name: String,
29 repo_name: String,
30 req: LfsBatchRequest,
31) -> Result<LfsBatchResponse, GitError> {
32 if req.objects.len() > MAX_BATCH_OBJECTS {
33 return Err(GitError::InvalidArgument(format!(
34 "batch request exceeds {MAX_BATCH_OBJECTS} objects"
35 )));
36 }
37 let ctx = svc.context(space_name.clone(), repo_name.clone()).await?;
38 if !ctx.model.enable_lfs {
39 return Err(GitError::NotFound);
40 }
41 let storage: &AppStorage = &svc.state.storage;
42 let cap = svc.state.config.git.max_blob_size;
43 let mut out: Vec<LfsBatchObject> = Vec::with_capacity(req.objects.len());
44 for obj in req.objects {
45 let oid = match canonical_oid(&obj.oid) {
46 Ok(oid) => oid,
47 Err(e) => {
48 out.push(object_error(obj.oid, obj.size, e));
49 continue;
50 }
51 };
52 if let Err(e) = validate_size(obj.size, cap) {
53 out.push(object_error(oid, obj.size, e));
54 continue;
55 }
56 let present = storage
57 .client()
58 .exists(&storage_key(&ctx.model.id, &oid))
59 .await
60 .map_err(|e| GitError::Internal(format!("storage exists: {e}")))?;
61 let actions = build_actions(
62 svc,
63 &ctx.model,
64 &space_name,
65 &repo_name,
66 &oid,
67 req.operation,
68 present,
69 )?;
70 let mut resp = LfsBatchObject {
71 oid,
72 size: obj.size,
73 actions,
74 error: None,
75 };
76 if req.operation == LfsOperation::Download && !present {
77 resp.error = Some(LfsError {
78 code: 404,
79 message: "object not found".to_string(),
80 });
81 }
82 out.push(resp);
83 }
84 Ok(LfsBatchResponse {
85 transfer: "basic".to_string(),
86 hash_algo: "sha256".to_string(),
87 objects: out,
88 })
89}
90
91fn build_actions(
92 svc: &GitService,
93 repo: &repos::Model,
94 space_name: &str,
95 repo_name: &str,
96 oid: &str,
97 operation: LfsOperation,
98 present: bool,
99) -> Result<HashMap<String, LfsAction>, GitError> {
100 let storage: &AppStorage = &svc.state.storage;
101 let mut actions: HashMap<String, LfsAction> = HashMap::new();
102 match (operation, present) {
103 (LfsOperation::Download, true) | (LfsOperation::Verify, true) => {
104 actions.insert(
105 "download".to_string(),
106 LfsAction {
107 href: storage
108 .client()
109 .presign_get(&storage_key(&repo.id, oid), DOWNLOAD_TTL_SECS)
110 .map_err(|e| GitError::Internal(format!("presign_get: {e}")))?,
111 header: None,
112 expires_at: Some(now_plus_secs(DOWNLOAD_TTL_SECS as i64)),
113 },
114 );
115 }
116 (LfsOperation::Upload, _) => {
117 let href = storage
118 .client()
119 .presign_put(&storage_key(&repo.id, oid), UPLOAD_TTL_SECS)
120 .map_err(|e| GitError::Internal(format!("presign_put: {e}")))?;
121 actions.insert(
122 "upload".to_string(),
123 LfsAction {
124 href: href.clone(),
125 header: None,
126 expires_at: Some(now_plus_secs(UPLOAD_TTL_SECS as i64)),
127 },
128 );
129 if !present {
130 actions.insert(
131 "verify".to_string(),
132 LfsAction {
133 href: verify_href(svc, space_name, repo_name),
134 header: None,
135 expires_at: Some(now_plus_secs(UPLOAD_TTL_SECS as i64)),
136 },
137 );
138 }
139 }
140 _ => {}
141 }
142 Ok(actions)
143}
144
145/// Stream an object upload: the reader is consumed chunk-by-chunk into
146/// storage while its bytes are hashed with SHA-256 on the fly. The hash is
147/// compared against the canonical oid only after the upload finishes; a
148/// mismatch (or declared-size mismatch) deletes the stored object again.
149/// Uploads are idempotent (already-present objects succeed without a second
150/// write). `repos.lfs_size` is incremented atomically.
151#[allow(clippy::too_many_arguments)]
152pub async fn upload_stream<R>(
153 svc: &GitService,
154 space_name: String,
155 repo_name: String,
156 caller: i64,
157 oid: String,
158 size: i64,
159 content_type: Option<String>,
160 reader: R,
161) -> Result<bool, GitError>
162where
163 R: tokio::io::AsyncRead + Unpin + Send,
164{
165 let _ = caller;
166 let ctx = svc.context(space_name, repo_name).await?;
167 if !ctx.model.enable_lfs {
168 return Err(GitError::NotFound);
169 }
170 let oid = canonical_oid(&oid)?;
171 validate_size(size, svc.state.config.git.max_blob_size)?;
172 let storage: &AppStorage = &svc.state.storage;
173 let key = storage_key(&ctx.model.id, &oid);
174 let existed = storage
175 .client()
176 .exists(&key)
177 .await
178 .map_err(|e| GitError::Internal(format!("storage exists: {e}")))?;
179
180 if !existed {
181 let mut reader = HashingReader::new(tokio::io::AsyncReadExt::take(reader, size as u64 + 1));
182 let sent = storage
183 .client()
184 .put_stream(
185 &key,
186 &mut reader,
187 content_type
188 .as_deref()
189 .unwrap_or("application/octet-stream"),
190 )
191 .await
192 .map_err(|e| GitError::Internal(format!("lfs put_stream: {e}")))?;
193 let (hex, _count) = reader.finish();
194 if sent as i64 != size {
195 let _ = storage.client().delete(&key).await;
196 return Err(GitError::InvalidArgument(format!(
197 "size mismatch: declared {size}, received {sent}"
198 )));
199 }
200 let actual = format!("sha256:{hex}");
201 if actual != oid {
202 let _ = storage.client().delete(&key).await;
203 return Err(GitError::InvalidArgument(format!(
204 "oid mismatch: expected {oid}, got {actual}"
205 )));
206 }
207 }
208 match git_lfs::Entity::insert(git_lfs::ActiveModel {
209 id: Set(Uuid::now_v7()),
210 repo_id: Set(ctx.model.id),
211 oid: Set(oid),
212 size: Set(size),
213 content_type: Set(content_type),
214 created_at: Set(OffsetDateTime::now_utc()),
215 })
216 .exec(svc.db_write())
217 .await
218 {
219 Ok(_) => {}
220 Err(db) => match GitError::from(db) {
221 GitError::Conflict(_) => {}
222 other => return Err(other),
223 },
224 }
225
226 if !existed {
227 let update = repos::Entity::update_many()
228 .col_expr(
229 repos::Column::LfsSize,
230 Expr::col(repos::Column::LfsSize).add(size),
231 )
232 .col_expr(
233 repos::Column::UpdatedAt,
234 Expr::val(OffsetDateTime::now_utc()),
235 )
236 .filter(repos::Column::Id.eq(ctx.model.id));
237 let res = if let Some(cap) = ctx.model.storage_limit_size {
238 let limit = cap.saturating_sub(size);
239 update
240 .filter(Expr::col(repos::Column::LfsSize).lte(limit))
241 .exec(svc.db_write())
242 .await?
243 } else {
244 update.exec(svc.db_write()).await?
245 };
246 if res.rows_affected == 0 {
247 let _ = storage.client().delete(&key).await;
248 return Err(GitError::InvalidArgument(
249 "lfs storage limit exceeded".to_string(),
250 ));
251 }
252 }
253 Ok(true)
254}
255
256/// In-memory convenience wrapper over [`upload_stream`] for small bodies.
257#[allow(clippy::too_many_arguments)]
258pub async fn upload(
259 svc: &GitService,
260 space_name: String,
261 repo_name: String,
262 caller: i64,
263 oid: String,
264 size: i64,
265 content_type: Option<String>,
266 body: Vec<u8>,
267) -> Result<bool, GitError> {
268 upload_stream(
269 svc,
270 space_name,
271 repo_name,
272 caller,
273 oid,
274 size,
275 content_type,
276 std::io::Cursor::new(body),
277 )
278 .await
279}
280
281/// `POST objects/verify` — confirm a stored object matches the recorded
282/// `(oid, size)`; fails with `NotFound` when the object or its DB row is
283/// missing.
284pub async fn verify(
285 svc: &GitService,
286 space_name: String,
287 repo_name: String,
288 req: LfsVerifyRequest,
289) -> Result<(), GitError> {
290 let oid = canonical_oid(&req.oid)?;
291 let ctx = svc.context(space_name, repo_name).await?;
292 let row = git_lfs::Entity::find()
293 .filter(git_lfs::Column::RepoId.eq(ctx.model.id))
294 .filter(git_lfs::Column::Oid.eq(&oid))
295 .one(svc.db_read())
296 .await?
297 .ok_or(GitError::NotFound)?;
298 if row.size != req.size {
299 return Err(GitError::InvalidArgument("size mismatch".to_string()));
300 }
301 let storage: &AppStorage = &svc.state.storage;
302 let present = storage
303 .client()
304 .exists(&storage_key(&ctx.model.id, &oid))
305 .await
306 .map_err(|e| GitError::Internal(format!("storage exists: {e}")))?;
307 if !present {
308 return Err(GitError::NotFound);
309 }
310 Ok(())
311}
312
313/// Stream an object into `writer` (bounded memory, no whole-object buffering).
314/// Returns the number of bytes written.
315pub async fn download_to<W>(
316 svc: &GitService,
317 space_name: String,
318 repo_name: String,
319 oid: String,
320 writer: &mut W,
321) -> Result<u64, GitError>
322where
323 W: tokio::io::AsyncWrite + Send + Unpin,
324{
325 let oid = canonical_oid(&oid)?;
326 let ctx = svc.context(space_name, repo_name).await?;
327 let storage: &AppStorage = &svc.state.storage;
328 storage
329 .client()
330 .get_to_writer(&storage_key(&ctx.model.id, &oid), writer)
331 .await
332 .map_err(|e| GitError::Internal(format!("lfs get: {e}")))
333}
334
335/// Whole-object convenience wrapper over [`download_to`] (use only for small
336/// objects; prefer the streaming variant in hot paths).
337pub async fn download(
338 svc: &GitService,
339 space_name: String,
340 repo_name: String,
341 oid: String,
342) -> Result<Vec<u8>, GitError> {
343 let mut buf = Vec::new();
344 download_to(svc, space_name, repo_name, oid, &mut buf).await?;
345 Ok(buf)
346}
347
348/// Consistency repair + unreferenced-object sweep for one repo:
349///
350/// 1. drop `git_lfs` rows whose storage object has vanished;
351/// 2. delete storage keys under `lfs/{repo}/` that have no DB row.
352///
353/// Returns the number of removed entries.
354pub async fn cleanup_unreferenced(
355 svc: &GitService,
356 space_name: String,
357 repo_name: String,
358) -> Result<i64, GitError> {
359 let ctx = svc.context(space_name, repo_name).await?;
360 let repo_id = ctx.model.id;
361 let storage: &AppStorage = &svc.state.storage;
362 let mut removed: i64 = 0;
363 let db = svc.db_write();
364
365 let rows = git_lfs::Entity::find()
366 .filter(git_lfs::Column::RepoId.eq(repo_id))
367 .all(db)
368 .await?;
369 for row in rows {
370 let key = storage_key(&repo_id, &row.oid);
371 let exists = storage
372 .client()
373 .exists(&key)
374 .await
375 .map_err(|e| GitError::Internal(format!("storage exists: {e}")))?;
376 if !exists {
377 git_lfs::Entity::delete_by_id(row.id).exec(db).await?;
378 removed += 1;
379 }
380 }
381
382 let prefix = format!("lfs/{}/", repo_id.simple());
383 let live: std::collections::HashSet<String> = git_lfs::Entity::find()
384 .filter(git_lfs::Column::RepoId.eq(repo_id))
385 .all(svc.db_read())
386 .await?
387 .into_iter()
388 .map(|r| r.oid)
389 .collect();
390 let mut token: Option<String> = None;
391 loop {
392 let page = storage
393 .client()
394 .list_page(&prefix, token.as_deref(), Some(200))
395 .await
396 .map_err(|e| GitError::Internal(format!("storage list: {e}")))?;
397 for obj in page.objects {
398 let Some(oid) = obj.key.strip_prefix(&prefix) else {
399 continue;
400 };
401 if !live.contains(oid) {
402 storage
403 .client()
404 .delete(&obj.key)
405 .await
406 .map_err(|e| GitError::Internal(format!("storage delete: {e}")))?;
407 removed += 1;
408 }
409 }
410 match page.next_continuation_token {
411 Some(next) if !next.is_empty() => token = Some(next),
412 _ => break,
413 }
414 }
415 Ok(removed)
416}
417
418fn object_error(oid: String, size: i64, err: GitError) -> LfsBatchObject {
419 LfsBatchObject {
420 oid,
421 size,
422 actions: HashMap::new(),
423 error: Some(LfsError {
424 code: crate::lfs::error::http_status(&err) as i32,
425 message: err.to_string(),
426 }),
427 }
428}
429
430fn storage_key(repo_id: &Uuid, oid: &str) -> String {
431 format!("lfs/{}/{}", repo_id.simple(), oid)
432}
433
434/// Tee between the upload reader and storage: every byte read is fed into a
435/// running SHA-256 and counted, so the upload can be verified without
436/// buffering the whole body.
437struct HashingReader<R> {
438 inner: R,
439 hasher: Sha256,
440 count: u64,
441}
442
443impl<R> HashingReader<R> {
444 fn new(inner: R) -> Self {
445 Self {
446 inner,
447 hasher: Sha256::new(),
448 count: 0,
449 }
450 }
451
452 fn finish(self) -> (String, u64) {
453 let hex = self
454 .hasher
455 .finalize()
456 .iter()
457 .map(|b| format!("{b:02x}"))
458 .collect();
459 (hex, self.count)
460 }
461}
462
463impl<R: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for HashingReader<R> {
464 fn poll_read(
465 mut self: Pin<&mut Self>,
466 cx: &mut std::task::Context<'_>,
467 buf: &mut tokio::io::ReadBuf<'_>,
468 ) -> Poll<std::io::Result<()>> {
469 let before = buf.filled().len();
470 let poll = Pin::new(&mut self.inner).poll_read(cx, buf);
471 if poll.is_ready() {
472 let written = &buf.filled()[before..];
473 if !(written.is_empty() && buf.remaining() > 0) {
474 self.hasher.update(written);
475 self.count += written.len() as u64;
476 }
477 }
478 poll
479 }
480}
481
482fn now_plus_secs(secs: i64) -> String {
483 OffsetDateTime::now_utc()
484 .saturating_add(time::Duration::seconds(secs))
485 .format(&time::format_description::well_known::Rfc3339)
486 .unwrap_or_default()
487}
488
489/// The batch `verify` action always points back at this server (the initial
490/// upload itself goes directly to the presigned S3 URL). The href follows the
491/// Gitea-style `/{space}/{repo}.git/info/lfs` layout, prefixed with
492/// `web.web_base_url` when configured.
493fn verify_href(svc: &GitService, space_name: &str, repo_name: &str) -> String {
494 let base = svc.state.config.web.web_base_url.trim_end_matches('/');
495 if base.is_empty() {
496 format!("/{}/{}.git/info/lfs/objects/verify", space_name, repo_name)
497 } else {
498 format!(
499 "{base}/{}/{}.git/info/lfs/objects/verify",
500 space_name, repo_name
501 )
502 }
503}
Added lib/git/src/rpcgit/archive/mod.rs View lib/git/src/rpcgit/archive/mod.rs
@@ -0,0 +1,2 @@
1pub mod types;
2pub use types::*;
Added lib/git/src/rpcgit/archive/types.rs View lib/git/src/rpcgit/archive/types.rs
@@ -0,0 +1,44 @@
1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6pub enum ArchiveFormat {
7 Tar,
8 TarGz,
9 TarBz2,
10 Zip,
11}
12
13impl fmt::Display for ArchiveFormat {
14 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15 match self {
16 Self::Tar => write!(f, "tar"),
17 Self::TarGz => write!(f, "tar.gz"),
18 Self::TarBz2 => write!(f, "tar.bz2"),
19 Self::Zip => write!(f, "zip"),
20 }
21 }
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct CreateArchiveRequest {
26 pub commit: String,
27 pub format: ArchiveFormat,
28 pub prefix: Option<String>,
29 pub paths: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct GetArchiveRequest {
34 pub commit: String,
35 pub format: ArchiveFormat,
36 pub prefix: Option<String>,
37 pub path: Option<String>,
38 pub exclude: Vec<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct BundleRequest {
43 pub refs: Vec<String>,
44}
Added lib/git/src/rpcgit/blame/mod.rs View lib/git/src/rpcgit/blame/mod.rs
@@ -0,0 +1,2 @@
1pub mod types;
2pub use types::*;
Renamed lib/gitbaby/src/blame/types.rs → lib/git/src/rpcgit/blame/types.rs View lib/git/src/rpcgit/blame/types.rs
@@ -4,8 +4,8 @@
44use serde::{Deserialize, Serialize};
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct BlameHunk {
7 pub sha: gix::ObjectId,
8 pub previous_sha: Option<gix::ObjectId>,
7 pub sha: crate::rpcgit::oid::ObjectId,
8 pub previous_sha: Option<crate::rpcgit::oid::ObjectId>,
99 pub previous_path: Option<PathBuf>,
1010 pub lines: Vec<String>,
1111 pub final_start_line: u32,
Added lib/git/src/rpcgit/blob/mod.rs View lib/git/src/rpcgit/blob/mod.rs
@@ -0,0 +1,2 @@
1pub mod types;
2pub use types::*;
Renamed lib/gitbaby/src/blob/types.rs → lib/git/src/rpcgit/blob/types.rs View lib/git/src/rpcgit/blob/types.rs
@@ -1,4 +1,4 @@
1use gix::ObjectId;
1use crate::rpcgit::oid::ObjectId;
22use serde::{Deserialize, Serialize};
33
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
Added lib/git/src/rpcgit/branch/mod.rs View lib/git/src/rpcgit/branch/mod.rs
@@ -0,0 +1,2 @@
1pub mod types;
2pub use types::*;
Renamed lib/gitbaby/src/branch/types.rs → lib/git/src/rpcgit/branch/types.rs View lib/git/src/rpcgit/branch/types.rs
@@ -1,4 +1,4 @@
1use gix::ObjectId;
1use crate::rpcgit::oid::ObjectId;
22use serde::{Deserialize, Serialize};
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct BranchInfo {
Added lib/git/src/rpcgit/commit/mod.rs View lib/git/src/rpcgit/commit/mod.rs
@@ -0,0 +1,2 @@
1pub mod types;
2pub use types::*;
Renamed lib/gitbaby/src/commit/types.rs → lib/git/src/rpcgit/commit/types.rs View lib/git/src/rpcgit/commit/types.rs
@@ -54,9 +54,9 @@
5454
5555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5656pub struct CommitInfo {
57 pub id: gix::ObjectId,
58 pub tree_id: gix::ObjectId,
59 pub parents: Vec<gix::ObjectId>,
57 pub id: crate::rpcgit::oid::ObjectId,
58 pub tree_id: crate::rpcgit::oid::ObjectId,
59 pub parents: Vec<crate::rpcgit::oid::ObjectId>,
6060 pub author: Signature,
6161 pub committer: Signature,
6262 pub message: String,
@@ -71,7 +71,7 @@
7171
7272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7373pub struct CommitShortStat {
74 pub oid: gix::ObjectId,
74 pub oid: crate::rpcgit::oid::ObjectId,
7575 pub author_name: String,
7676 pub author_email: String,
7777 pub additions: u64,
@@ -80,7 +80,7 @@
8080
8181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8282pub struct TreeEntry {
83 pub oid: gix::ObjectId,
83 pub oid: crate::rpcgit::oid::ObjectId,
8484 pub name: String,
8585 pub mode: TreeEntryMode,
8686 pub size: Option<u64>,
Added lib/git/src/rpcgit/compare/mod.rs View lib/git/src/rpcgit/compare/mod.rs
@@ -0,0 +1,2 @@
1pub mod types;
2pub use types::*;
Renamed lib/gitbaby/src/compare/types.rs → lib/git/src/rpcgit/compare/types.rs View lib/git/src/rpcgit/compare/types.rs

No content changes.

Added lib/git/src/rpcgit/conflict/mod.rs View lib/git/src/rpcgit/conflict/mod.rs
@@ -0,0 +1,2 @@
1pub mod types;
2pub use types::*;
Renamed lib/gitbaby/src/conflict/types.rs → lib/git/src/rpcgit/conflict/types.rs View lib/git/src/rpcgit/conflict/types.rs
@@ -1,9 +1,9 @@
11use std::path::PathBuf;
22
3use gix::ObjectId;
3use crate::rpcgit::oid::ObjectId;
44use serde::{Deserialize, Serialize};
55
6use crate::commit::types::Signature;
6use crate::rpcgit::commit::types::Signature;
77
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99pub enum MergeStage {
Added lib/git/src/rpcgit/diff/mod.rs View lib/git/src/rpcgit/diff/mod.rs
@@ -0,0 +1,2 @@
1pub mod types;
2pub use types::*;
Renamed lib/gitbaby/src/diff/types.rs → lib/git/src/rpcgit/diff/types.rs View lib/git/src/rpcgit/diff/types.rs
@@ -1,6 +1,6 @@
11use std::path::PathBuf;
22
3use gix::ObjectId;
3use crate::rpcgit::oid::ObjectId;
44use serde::{Deserialize, Serialize};
55
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
Added lib/git/src/rpcgit/refs/mod.rs View lib/git/src/rpcgit/refs/mod.rs
@@ -0,0 +1,2 @@
1pub mod types;
2pub use types::*;
Renamed lib/gitbaby/src/refs/types.rs → lib/git/src/rpcgit/refs/types.rs View lib/git/src/rpcgit/refs/types.rs
@@ -1,4 +1,4 @@
1use gix::ObjectId;
1use crate::rpcgit::oid::ObjectId;
22use serde::{Deserialize, Serialize};
33
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
Added lib/git/src/rpcgit/remote/mod.rs View lib/git/src/rpcgit/remote/mod.rs
@@ -0,0 +1,2 @@
1pub mod types;
2pub use types::*;
Renamed lib/gitbaby/src/remote/types.rs → lib/git/src/rpcgit/remote/types.rs View lib/git/src/rpcgit/remote/types.rs
@@ -8,16 +8,6 @@
88 MirrorFetch,
99}
1010
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
2111#[derive(Debug, Clone, Serialize, Deserialize)]
2212pub struct UpdateRemoteMirrorRequest {
2313 pub url: String,
Added lib/git/src/rpcgit/setup/mod.rs View lib/git/src/rpcgit/setup/mod.rs
@@ -0,0 +1,2 @@
1pub mod types;
2pub use types::*;
Renamed lib/gitbaby/src/setup/types.rs → lib/git/src/rpcgit/setup/types.rs View lib/git/src/rpcgit/setup/types.rs

No content changes.

Added lib/git/src/rpcgit/submodule/mod.rs View lib/git/src/rpcgit/submodule/mod.rs
@@ -0,0 +1,2 @@
1pub mod types;
2pub use types::*;
Renamed lib/gitbaby/src/submodule/types.rs → lib/git/src/rpcgit/submodule/types.rs View lib/git/src/rpcgit/submodule/types.rs

No content changes.

Added lib/git/src/rpcgit/tags/mod.rs View lib/git/src/rpcgit/tags/mod.rs
@@ -0,0 +1,2 @@
1pub mod types;
2pub use types::*;
Renamed lib/gitbaby/src/tags/types.rs → lib/git/src/rpcgit/tags/types.rs View lib/git/src/rpcgit/tags/types.rs
@@ -1,4 +1,4 @@
1use gix::ObjectId;
1use crate::rpcgit::oid::ObjectId;
22use serde::{Deserialize, Serialize};
33
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -26,7 +26,7 @@
2626 pub sha: ObjectId,
2727 pub target_sha: ObjectId,
2828 pub tag_type: TagType,
29 pub tagger: Option<crate::commit::types::Signature>,
29 pub tagger: Option<crate::rpcgit::commit::types::Signature>,
3030 pub message: Option<String>,
3131}
3232
Added lib/git/src/rpcgit/tree/mod.rs View lib/git/src/rpcgit/tree/mod.rs
@@ -0,0 +1,2 @@
1pub mod types;
2pub use types::*;
Renamed lib/gitbaby/src/tree/types.rs → lib/git/src/rpcgit/tree/types.rs View lib/git/src/rpcgit/tree/types.rs
@@ -1,8 +1,8 @@
1use gix::ObjectId;
1use crate::rpcgit::oid::ObjectId;
22use serde::{Deserialize, Serialize};
33use std::path::PathBuf;
44
5use crate::commit::types::{CommitInfo, TreeEntry, TreeEntryMode};
5use crate::rpcgit::commit::types::{CommitInfo, TreeEntry, TreeEntryMode};
66
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88pub enum TreeDiffStatus {
Added lib/git/src/rpcgit/api.rs View lib/git/src/rpcgit/api.rs
@@ -0,0 +1,1651 @@
1use std::path::{Path, PathBuf};
2
3use kenshell::client::Intercepted;
4use kenshell::proto::common::v1::PageRequest;
5use kenshell::proto::v1 as pb;
6use kenshell::proto::v1::commit_service_client::CommitServiceClient;
7use kenshell::proto::v1::diff_service_client::DiffServiceClient;
8use kenshell::proto::v1::ref_service_client::RefServiceClient;
9use kenshell::proto::v1::tree_service_client::TreeServiceClient;
10
11use crate::rpcgit::GitBaby;
12use crate::rpcgit::archive::types::{
13 ArchiveFormat, BundleRequest, CreateArchiveRequest, GetArchiveRequest,
14};
15use crate::rpcgit::blame::types::{BlameHunk, BlameOptions};
16use crate::rpcgit::blob::types::{
17 BlobContent, BlobInfo, GetBlobOptions, LFSPointer, ListLFSPointersOptions,
18};
19use crate::rpcgit::branch::types::{BranchInfo, ListBranchesOptions};
20use crate::rpcgit::commit::types::{
21 CommitInfo, CommitShortStat, CommitStats, ListCommitsOptions, Signature, TreeEntry,
22 TreeEntryMode,
23};
24use crate::rpcgit::conflict::types::ListConflictOptions;
25use crate::rpcgit::diff::types::{
26 ChangedPath, ChangedPathDiff, ChangedPathRequest, DiffOptions, DiffRequest, DiffStatus,
27 NumStat, RangeDiffSpec, ShortStat,
28};
29use crate::rpcgit::error::BabyError;
30use crate::rpcgit::oid::ObjectId;
31use crate::rpcgit::refs::types::{ListRefsOptions, RefInfo, RefType};
32use crate::rpcgit::remote::types::{RemoteOption, UpdateRemoteMirrorRequest};
33use crate::rpcgit::setup::types::{FastImportCommit, ObjectFormat};
34use crate::rpcgit::tags::types::{
35 CreateTagOptions, ListTagsOptions, TagInfo, TagType, TagVerifyResult,
36};
37use crate::rpcgit::tree::types::{
38 DiffTreesOptions, TreeDiff, TreeDiffStatus, TreeEntryWithLatest, WalkTreeOptions,
39};
40
41fn unsupported(what: &str) -> BabyError {
42 BabyError::Custom(format!("{what} is not provided by KenShell"))
43}
44
45fn oid(text: &str) -> Result<ObjectId, BabyError> {
46 ObjectId::parse(text).map_err(|_| BabyError::RevisionInvalid(text.to_string()))
47}
48
49fn oid_opt(text: &str) -> Option<ObjectId> {
50 if text.trim().is_empty() {
51 None
52 } else {
53 ObjectId::parse(text).ok()
54 }
55}
56
57fn signature(person: Option<pb::CommitPerson>) -> Signature {
58 let person = person.unwrap_or_default();
59 Signature {
60 name: person.name,
61 email: person.email,
62 time: person
63 .at
64 .and_then(|at| time::OffsetDateTime::from_unix_timestamp(at.seconds).ok())
65 .unwrap_or(time::OffsetDateTime::UNIX_EPOCH),
66 }
67}
68
69fn mode_of(text: &str) -> TreeEntryMode {
70 TreeEntryMode::from_octal_str(text).unwrap_or(TreeEntryMode::Blob)
71}
72
73fn entry_from_view(view: pb::TreeEntryView) -> TreeEntry {
74 let name = if view.name_bytes.is_empty() {
75 view.name
76 } else {
77 String::from_utf8_lossy(&view.name_bytes).into_owned()
78 };
79 let mode =
80 match pb::TreeEntryType::try_from(view.r#type).unwrap_or(pb::TreeEntryType::Unspecified) {
81 pb::TreeEntryType::Submodule => TreeEntryMode::Submodule,
82 pb::TreeEntryType::Symlink => TreeEntryMode::Symlink,
83 _ => mode_of(&view.mode),
84 };
85 TreeEntry {
86 oid: oid(&view.oid).unwrap_or_else(|_| {
87 oid_opt("4b825dc642cb6eb9a060e54bf8d69288fbee4904").expect("empty tree")
88 }),
89 name,
90 mode,
91 size: Some(view.size_bytes),
92 }
93}
94
95fn entry_from_view_path(view: pb::TreeEntryView) -> TreeEntry {
96 let mut entry = entry_from_view(view.clone());
97 if !view.path.is_empty() {
98 entry.name = view.path;
99 }
100 entry
101}
102
103fn commit_from_view(view: pb::CommitView) -> CommitInfo {
104 CommitInfo {
105 id: oid(&view.id)
106 .unwrap_or_else(|_| oid_opt("0000000000000000000000000000000000000000").expect("zero")),
107 tree_id: oid(&view.tree_id).unwrap_or_else(|_| {
108 oid_opt("4b825dc642cb6eb9a060e54bf8d69288fbee4904").expect("empty tree")
109 }),
110 parents: view
111 .parent_ids
112 .iter()
113 .filter_map(|p| ObjectId::parse(p).ok())
114 .collect(),
115 author: signature(view.author),
116 committer: signature(view.committer),
117 message: view.message,
118 gpg_signature: None,
119 }
120}
121
122fn ref_type(value: i32) -> RefType {
123 match pb::RefType::try_from(value).unwrap_or(pb::RefType::Unspecified) {
124 pb::RefType::Branch => RefType::Branch,
125 pb::RefType::Tag => RefType::Tag,
126 pb::RefType::RemoteBranch => RefType::Remote,
127 _ => RefType::Other,
128 }
129}
130
131fn path_or_empty(path: Option<PathBuf>) -> String {
132 path.map(|p| p.to_string_lossy().into_owned())
133 .unwrap_or_default()
134}
135
136fn page_request(page_size: u32, token: String) -> PageRequest {
137 PageRequest {
138 page_size,
139 page_token: token,
140 }
141}
142
143fn diff_status(text: &str) -> DiffStatus {
144 match text {
145 "A" | "added" | "Added" => DiffStatus::Added,
146 "D" | "deleted" | "Deleted" => DiffStatus::Deleted,
147 "T" | "type_changed" | "TypeChanged" => DiffStatus::TypeChanged,
148 "R" | "renamed" | "Renamed" => DiffStatus::Renamed,
149 "C" | "copied" | "Copied" => DiffStatus::Copied,
150 _ => DiffStatus::Modified,
151 }
152}
153
154fn changed_path(view: pb::ChangedPathView) -> ChangedPath {
155 ChangedPath {
156 old_path: if view.old_path.is_empty() {
157 None
158 } else {
159 Some(PathBuf::from(view.old_path))
160 },
161 new_path: PathBuf::from(view.new_path),
162 old_mode: view.old_mode,
163 new_mode: view.new_mode,
164 old_oid: oid_opt(&view.old_oid),
165 new_oid: oid_opt(&view.new_oid),
166 status: diff_status(&view.status),
167 }
168}
169
170impl GitBaby {
171 fn common_ref(&self) -> kenshell::proto::common::v1::RepositoryRef {
172 self.context().repository_ref()
173 }
174
175 fn refs_client(&self) -> RefServiceClient<Intercepted> {
176 self.context().ks.refs()
177 }
178
179 fn commit_client(&self) -> CommitServiceClient<Intercepted> {
180 self.context().ks.commit()
181 }
182
183 fn tree_client(&self) -> TreeServiceClient<Intercepted> {
184 self.context().ks.tree()
185 }
186
187 fn diff_client(&self) -> DiffServiceClient<Intercepted> {
188 self.context().ks.diff()
189 }
190
191 fn repository_ref_some(&self) -> Option<kenshell::proto::common::v1::RepositoryRef> {
192 Some(self.common_ref())
193 }
194
195 pub async fn rev_parse(&self, revision: &str) -> Result<ObjectId, BabyError> {
196 let mut client = self.commit_client();
197 let response = client
198 .rev_parse(pb::RevParseRequest {
199 repository: self.repository_ref_some(),
200 revision: revision.to_string(),
201 })
202 .await
203 .map_err(BabyError::from)?
204 .into_inner();
205 oid(&response.oid)
206 }
207
208 pub async fn resolve_revision(&self, revision: &str) -> Result<ObjectId, BabyError> {
209 self.rev_parse(revision).await
210 }
211
212 pub async fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool, BabyError> {
213 let mut client = self.commit_client();
214 let response = client
215 .is_ancestor(pb::IsAncestorRequest {
216 repository: self.repository_ref_some(),
217 ancestor: ancestor.to_string(),
218 descendant: descendant.to_string(),
219 })
220 .await
221 .map_err(BabyError::from)?
222 .into_inner();
223 Ok(response.is_ancestor)
224 }
225
226 pub async fn get_commit(&self, revision: &str) -> Result<CommitInfo, BabyError> {
227 let mut client = self.commit_client();
228 let response = client
229 .get_commit(pb::GetCommitRequest {
230 repository: self.repository_ref_some(),
231 commit_id: revision.to_string(),
232 })
233 .await
234 .map_err(BabyError::from)?
235 .into_inner();
236 response
237 .commit
238 .map(commit_from_view)
239 .ok_or_else(|| BabyError::RevisionNotFound(revision.to_string()))
240 }
241
242 pub async fn list_commits(
243 &self,
244 opts: ListCommitsOptions,
245 ) -> Result<Vec<CommitInfo>, BabyError> {
246 let limit = opts.max_count.unwrap_or(50).max(1);
247 let skip = opts.skip.unwrap_or(0);
248 let mut out = Vec::new();
249 let mut token = String::new();
250 let mut seen = 0u32;
251 loop {
252 let mut client = self.commit_client();
253 let response = client
254 .list_commits(pb::ListCommitsRequest {
255 repository: self.repository_ref_some(),
256 revision: opts.revision.clone(),
257 path: path_or_empty(opts.path.clone()),
258 query: String::new(),
259 first_parent: opts.first_parent,
260 page: Some(page_request(limit.min(200), token.clone())),
261 since: opts.since.clone().unwrap_or_default(),
262 until: opts.until.clone().unwrap_or_default(),
263 })
264 .await
265 .map_err(BabyError::from)?
266 .into_inner();
267 for commit in response.commits {
268 if seen >= skip {
269 out.push(commit_from_view(commit));
270 }
271 seen += 1;
272 }
273 let next = response
274 .page
275 .as_ref()
276 .map(|p| p.next_page_token.clone())
277 .unwrap_or_default();
278 if out.len() >= limit as usize || next.is_empty() {
279 break;
280 }
281 token = next;
282 }
283 out.truncate(limit as usize);
284 Ok(out)
285 }
286
287 pub async fn count_commits(&self, opts: ListCommitsOptions) -> Result<u64, BabyError> {
288 let mut client = self.commit_client();
289 let response = client
290 .list_commits(pb::ListCommitsRequest {
291 repository: self.repository_ref_some(),
292 revision: opts.revision,
293 path: path_or_empty(opts.path),
294 query: String::new(),
295 first_parent: opts.first_parent,
296 page: Some(page_request(1, String::new())),
297 since: opts.since.unwrap_or_default(),
298 until: opts.until.unwrap_or_default(),
299 })
300 .await
301 .map_err(BabyError::from)?
302 .into_inner();
303 Ok(response
304 .total
305 .or_else(|| response.page.as_ref().and_then(|p| p.total_size))
306 .unwrap_or(0))
307 }
308
309 pub async fn commit_stats(
310 &self,
311 revision: &str,
312 path: Option<&Path>,
313 ) -> Result<CommitStats, BabyError> {
314 let mut client = self.commit_client();
315 let response = client
316 .get_commit_stats(pb::GetCommitStatsRequest {
317 repository: self.repository_ref_some(),
318 commit_id: revision.to_string(),
319 path: path
320 .map(|p| p.to_string_lossy().into_owned())
321 .unwrap_or_default(),
322 })
323 .await
324 .map_err(BabyError::from)?
325 .into_inner();
326 let stats = response.stats.unwrap_or_default();
327 Ok(CommitStats {
328 additions: stats.additions as u64,
329 deletions: stats.deletions as u64,
330 })
331 }
332
333 pub async fn commit_short_stats(
334 &self,
335 revision: &str,
336 ) -> Result<Vec<CommitShortStat>, BabyError> {
337 let commits = self
338 .list_commits(ListCommitsOptions {
339 revision: revision.to_string(),
340 max_count: Some(200),
341 ..Default::default()
342 })
343 .await?;
344 let mut out = Vec::with_capacity(commits.len());
345 for commit in commits {
346 let stats = self
347 .commit_stats(&commit.id.to_string(), None)
348 .await
349 .unwrap_or_default();
350 out.push(CommitShortStat {
351 oid: commit.id,
352 author_name: commit.author.name,
353 author_email: commit.author.email,
354 additions: stats.additions,
355 deletions: stats.deletions,
356 });
357 }
358 Ok(out)
359 }
360
361 pub async fn blame_all(&self, opts: BlameOptions) -> Result<Vec<BlameHunk>, BabyError> {
362 let mut client = self.commit_client();
363 let mut token = String::new();
364 let mut hunks: Vec<BlameHunk> = Vec::new();
365 loop {
366 let response = client
367 .list_blame(pb::ListBlameRequest {
368 repository: self.repository_ref_some(),
369 revision: opts.revision.clone(),
370 path: opts.path.to_string_lossy().into_owned(),
371 page: Some(page_request(500, token.clone())),
372 })
373 .await
374 .map_err(BabyError::from)?
375 .into_inner();
376 for line in response.lines {
377 let sha = ObjectId::parse(&line.commit_id).unwrap_or_else(|_| {
378 ObjectId::parse("0000000000000000000000000000000000000000").expect("zero")
379 });
380 match hunks.last_mut() {
381 Some(last) if last.sha == sha => {
382 last.lines.push(line.content);
383 last.final_line_count += 1;
384 }
385 _ => hunks.push(BlameHunk {
386 sha,
387 previous_sha: None,
388 previous_path: None,
389 lines: vec![line.content],
390 final_start_line: line.line_number,
391 final_line_count: 1,
392 orig_start_line: 0,
393 }),
394 }
395 }
396 let next = response
397 .page
398 .as_ref()
399 .map(|p| p.next_page_token.clone())
400 .unwrap_or_default();
401 if next.is_empty() {
402 break;
403 }
404 token = next;
405 }
406 Ok(hunks)
407 }
408
409 pub async fn get_tree_entries(
410 &self,
411 revision: &str,
412 path: &Path,
413 ) -> Result<Vec<TreeEntry>, BabyError> {
414 let mut client = self.tree_client();
415 let response = client
416 .get_tree(pb::GetTreeRequest {
417 repository: self.repository_ref_some(),
418 revision: revision.to_string(),
419 path: path.to_string_lossy().into_owned(),
420 page: None,
421 path_bytes: Vec::new(),
422 })
423 .await
424 .map_err(BabyError::from)?
425 .into_inner();
426 Ok(response.entries.into_iter().map(entry_from_view).collect())
427 }
428
429 pub async fn tree_entry_at(
430 &self,
431 revision: &str,
432 path: &Path,
433 ) -> Result<Option<TreeEntry>, BabyError> {
434 let mut client = self.tree_client();
435 let response = client
436 .resolve_tree_path(pb::ResolveTreePathRequest {
437 repository: self.repository_ref_some(),
438 revision: revision.to_string(),
439 path: path.to_string_lossy().into_owned(),
440 path_bytes: Vec::new(),
441 })
442 .await;
443 match response {
444 Ok(response) => Ok(response.into_inner().entry.map(entry_from_view)),
445 Err(status) if status.code() == tonic::Code::NotFound => Ok(None),
446 Err(status) => Err(BabyError::from(status)),
447 }
448 }
449
450 pub async fn walk_tree(
451 &self,
452 revision: &str,
453 path: &Path,
454 opts: WalkTreeOptions,
455 ) -> Result<Vec<TreeEntry>, BabyError> {
456 let mut client = self.tree_client();
457 let mut token = String::new();
458 let mut out = Vec::new();
459 loop {
460 let response = client
461 .list_files(pb::ListFilesRequest {
462 repository: self.repository_ref_some(),
463 revision: revision.to_string(),
464 path: path.to_string_lossy().into_owned(),
465 recursive: opts.recurse,
466 page: Some(page_request(200, token.clone())),
467 include_directories: opts.show_trees,
468 path_bytes: Vec::new(),
469 })
470 .await
471 .map_err(BabyError::from)?
472 .into_inner();
473 out.extend(response.files.into_iter().map(entry_from_view_path));
474 let next = response
475 .page
476 .as_ref()
477 .map(|p| p.next_page_token.clone())
478 .unwrap_or_default();
479 if next.is_empty() {
480 break;
481 }
482 token = next;
483 }
484 Ok(out)
485 }
486
487 pub async fn list_dir(
488 &self,
489 revision: &str,
490 path: Option<&str>,
491 ) -> Result<Vec<TreeEntry>, BabyError> {
492 let mut client = self.tree_client();
493 let response = client
494 .get_tree(pb::GetTreeRequest {
495 repository: self.repository_ref_some(),
496 revision: revision.to_string(),
497 path: path.unwrap_or("").to_string(),
498 page: None,
499 path_bytes: Vec::new(),
500 })
501 .await
502 .map_err(BabyError::from)?
503 .into_inner();
504 Ok(response.entries.into_iter().map(entry_from_view).collect())
505 }
506
507 async fn entries_with_latest(
508 &self,
509 revision: &str,
510 path: Option<&str>,
511 recursive: bool,
512 ) -> Result<Vec<TreeEntryWithLatest>, BabyError> {
513 let mut client = self.tree_client();
514 let mut token = String::new();
515 let mut out = Vec::new();
516 loop {
517 let response = client
518 .get_tree(pb::GetTreeRequest {
519 repository: self.repository_ref_some(),
520 revision: revision.to_string(),
521 path: path.unwrap_or("").to_string(),
522 page: Some(page_request(200, token.clone())),
523 path_bytes: Vec::new(),
524 })
525 .await
526 .map_err(BabyError::from)?
527 .into_inner();
528 for entry in response.entries {
529 let latest = entry
530 .latest_commit
531 .clone()
532 .map(commit_from_view)
533 .unwrap_or_else(|| commit_from_view(pb::CommitView::default()));
534 out.push(TreeEntryWithLatest {
535 entry: entry_from_view(entry),
536 latest,
537 });
538 }
539 let next = response
540 .page
541 .as_ref()
542 .map(|p| p.next_page_token.clone())
543 .unwrap_or_default();
544 if next.is_empty() {
545 break;
546 }
547 token = next;
548 }
549 if recursive {
550 let mut full = Vec::new();
551 for item in out {
552 if item.entry.mode == TreeEntryMode::Tree {
553 continue;
554 }
555 full.push(item);
556 }
557 return Ok(full);
558 }
559 Ok(out)
560 }
561
562 pub async fn tree_entries_with_latest(
563 &self,
564 revision: &str,
565 path: Option<&str>,
566 ) -> Result<Vec<TreeEntryWithLatest>, BabyError> {
567 self.entries_with_latest(revision, path, false).await
568 }
569
570 pub async fn tree_dir_entries_with_latest(
571 &self,
572 revision: &str,
573 path: Option<&str>,
574 ) -> Result<Vec<TreeEntryWithLatest>, BabyError> {
575 self.entries_with_latest(revision, path, false).await
576 }
577
578 pub async fn get_blob(
579 &self,
580 oid_value: ObjectId,
581 opts: GetBlobOptions,
582 ) -> Result<BlobContent, BabyError> {
583 let mut client = self.context().ks.blob();
584 let response = client
585 .get_blob_content(pb::GetBlobContentRequest {
586 repository: self.repository_ref_some(),
587 oid: oid_value.to_string(),
588 max_bytes: opts.limit.unwrap_or(0).max(0) as u64,
589 })
590 .await
591 .map_err(BabyError::from)?
592 .into_inner();
593 let content = response.content.unwrap_or_default();
594 let size = content.size;
595 Ok(BlobContent {
596 info: BlobInfo {
597 oid: oid_opt(&content.oid).unwrap_or(oid_value),
598 size,
599 data: Some(content.data.clone()),
600 },
601 data: content.data,
602 })
603 }
604
605 pub async fn get_blob_size(&self, oid_value: ObjectId) -> Result<i64, BabyError> {
606 let mut client = self.context().ks.blob();
607 let response = client
608 .get_blob_metadata(pb::GetBlobMetadataRequest {
609 repository: self.repository_ref_some(),
610 oid: oid_value.to_string(),
611 })
612 .await
613 .map_err(BabyError::from)?
614 .into_inner();
615 Ok(response.metadata.map(|m| m.size).unwrap_or(0))
616 }
617
618 pub async fn list_lfs_pointers(
619 &self,
620 opts: ListLFSPointersOptions,
621 ) -> Result<Vec<LFSPointer>, BabyError> {
622 let mut client = self.context().ks.blob();
623 let response = client
624 .list_lfs_pointers(pb::ListLfsPointersRequest {
625 repository: self.repository_ref_some(),
626 revision: opts.revisions.first().cloned().unwrap_or_default(),
627 limit: opts.limit.unwrap_or(100),
628 })
629 .await
630 .map_err(BabyError::from)?
631 .into_inner();
632 Ok(response
633 .pointers
634 .into_iter()
635 .map(|p| LFSPointer {
636 oid: oid_opt(&p.oid).unwrap_or_else(|| {
637 oid_opt("0000000000000000000000000000000000000000").expect("zero")
638 }),
639 size: p.size,
640 file_oid: oid_opt("0000000000000000000000000000000000000000").expect("zero"),
641 file_size: p.size,
642 content: Vec::new(),
643 })
644 .collect())
645 }
646
647 pub async fn list_refs(&self, opts: ListRefsOptions) -> Result<Vec<RefInfo>, BabyError> {
648 let mut client = self.refs_client();
649 let mut out = Vec::new();
650 let mut token = String::new();
651 let prefix = opts.patterns.first().cloned().unwrap_or_default();
652 let limit = opts.limit.unwrap_or(500);
653 loop {
654 let response = client
655 .list_refs(pb::ListRefsRequest {
656 repository: self.repository_ref_some(),
657 prefix: prefix.clone(),
658 page: Some(page_request(limit.min(200), token.clone())),
659 })
660 .await
661 .map_err(BabyError::from)?
662 .into_inner();
663 for item in response.refs {
664 out.push(RefInfo {
665 name: item.name,
666 ref_type: ref_type(item.r#type),
667 sha: oid(&item.sha).unwrap_or_else(|_| {
668 oid_opt("0000000000000000000000000000000000000000").expect("zero")
669 }),
670 peeled: None,
671 });
672 }
673 let next = response
674 .page
675 .as_ref()
676 .map(|p| p.next_page_token.clone())
677 .unwrap_or_default();
678 if next.is_empty() || out.len() >= limit as usize {
679 break;
680 }
681 token = next;
682 }
683 if !opts.patterns.is_empty() {
684 out.retain(|r| opts.patterns.iter().any(|p| r.name.contains(p.as_str())));
685 }
686 if !opts.exclude_patterns.is_empty() {
687 out.retain(|r| {
688 !opts
689 .exclude_patterns
690 .iter()
691 .any(|p| r.name.contains(p.as_str()))
692 });
693 }
694 Ok(out)
695 }
696
697 pub async fn find_refs_by_oid(&self, target: ObjectId) -> Result<Vec<RefInfo>, BabyError> {
698 let refs = self.list_refs(ListRefsOptions::default()).await?;
699 Ok(refs.into_iter().filter(|r| r.sha == target).collect())
700 }
701
702 pub async fn ref_exists(&self, name: &str) -> Result<bool, BabyError> {
703 let mut client = self.refs_client();
704 let response = client
705 .get_ref(pb::GetRefRequest {
706 repository: self.repository_ref_some(),
707 name: name.to_string(),
708 })
709 .await;
710 match response {
711 Ok(_) => Ok(true),
712 Err(status) if status.code() == tonic::Code::NotFound => Ok(false),
713 Err(status) => Err(BabyError::from(status)),
714 }
715 }
716
717 pub async fn update_ref(
718 &self,
719 name: &str,
720 new_sha: ObjectId,
721 old_sha: Option<ObjectId>,
722 ) -> Result<(), BabyError> {
723 let branch = name
724 .strip_prefix("refs/heads/")
725 .ok_or_else(|| unsupported("updating non-branch refs"))?;
726 let mut client = self.context().ks.command();
727 client
728 .update_branch(pb::UpdateBranchRequest {
729 repository: self.repository_ref_some(),
730 name: branch.to_string(),
731 new_sha: new_sha.to_string(),
732 expected_old_sha: old_sha.map(|o| o.to_string()).unwrap_or_default(),
733 actor: Some(self.context().actor()),
734 options: Some(self.context().write_options()),
735 })
736 .await
737 .map_err(BabyError::from)?;
738 Ok(())
739 }
740
741 pub async fn delete_ref(&self, name: &str) -> Result<(), BabyError> {
742 let branch = name
743 .strip_prefix("refs/heads/")
744 .ok_or_else(|| unsupported("deleting non-branch refs"))?;
745 let mut client = self.context().ks.command();
746 client
747 .delete_branch(pb::DeleteBranchRequest {
748 repository: self.repository_ref_some(),
749 name: branch.to_string(),
750 expected_sha: String::new(),
751 actor: Some(self.context().actor()),
752 options: Some(self.context().write_options()),
753 force: true,
754 })
755 .await
756 .map_err(BabyError::from)?;
757 Ok(())
758 }
759
760 pub async fn set_default_branch(&self, name: &str) -> Result<(), BabyError> {
761 let mut client = self.context().ks.command();
762 client
763 .set_default_branch(pb::SetDefaultBranchRequest {
764 repository: self.repository_ref_some(),
765 name: name.to_string(),
766 actor: Some(self.context().actor()),
767 options: Some(self.context().write_options()),
768 })
769 .await
770 .map_err(BabyError::from)?;
771 Ok(())
772 }
773
774 pub async fn get_default_branch(&self) -> Result<String, BabyError> {
775 let mut client = self.refs_client();
776 let response = client
777 .get_default_branch(pb::GetDefaultBranchRequest {
778 repository: self.repository_ref_some(),
779 })
780 .await
781 .map_err(BabyError::from)?
782 .into_inner();
783 if response.name.is_empty() {
784 Err(BabyError::RevisionNotFound("HEAD".into()))
785 } else {
786 Ok(response.name)
787 }
788 }
789
790 pub async fn list_branches(
791 &self,
792 opts: ListBranchesOptions,
793 ) -> Result<Vec<BranchInfo>, BabyError> {
794 let mut client = self.refs_client();
795 let mut token = String::new();
796 let mut out = Vec::new();
797 loop {
798 let response = client
799 .list_branches(pb::ListBranchesRequest {
800 repository: self.repository_ref_some(),
801 query: opts.patterns.first().cloned().unwrap_or_default(),
802 sort: pb::BranchSort::NameAsc as i32,
803 page: Some(page_request(200, token.clone())),
804 })
805 .await
806 .map_err(BabyError::from)?
807 .into_inner();
808 for branch in response.branches {
809 out.push(BranchInfo {
810 name: branch.name,
811 sha: oid(&branch.sha).unwrap_or_else(|_| {
812 oid_opt("0000000000000000000000000000000000000000").expect("zero")
813 }),
814 });
815 }
816 let next = response
817 .page
818 .as_ref()
819 .map(|p| p.next_page_token.clone())
820 .unwrap_or_default();
821 if next.is_empty() {
822 break;
823 }
824 token = next;
825 }
826 Ok(out)
827 }
828
829 pub async fn find_branch(&self, name: &str) -> Result<BranchInfo, BabyError> {
830 let mut client = self.refs_client();
831 let response = client
832 .get_branch(pb::GetBranchRequest {
833 repository: self.repository_ref_some(),
834 name: name.to_string(),
835 })
836 .await
837 .map_err(BabyError::from)?
838 .into_inner();
839 let branch = response
840 .branch
841 .ok_or_else(|| BabyError::BranchNotFound(name.to_string()))?;
842 Ok(BranchInfo {
843 name: branch.name,
844 sha: oid(&branch.sha).unwrap_or_else(|_| {
845 oid_opt("0000000000000000000000000000000000000000").expect("zero")
846 }),
847 })
848 }
849
850 pub async fn branch_exists(&self, name: &str) -> Result<bool, BabyError> {
851 match self.find_branch(name).await {
852 Ok(_) => Ok(true),
853 Err(BabyError::BranchNotFound(_)) => Ok(false),
854 Err(error) => Err(error),
855 }
856 }
857
858 pub async fn create_branch(
859 &self,
860 name: &str,
861 start_point: Option<&str>,
862 ) -> Result<(), BabyError> {
863 let mut client = self.context().ks.command();
864 client
865 .create_branch(pb::CreateBranchRequest {
866 repository: self.repository_ref_some(),
867 name: name.to_string(),
868 start_point: start_point.unwrap_or("").to_string(),
869 actor: Some(self.context().actor()),
870 options: Some(self.context().write_options()),
871 })
872 .await
873 .map_err(BabyError::from)?;
874 Ok(())
875 }
876
877 pub async fn delete_branch(&self, name: &str, force: bool) -> Result<(), BabyError> {
878 let mut client = self.context().ks.command();
879 client
880 .delete_branch(pb::DeleteBranchRequest {
881 repository: self.repository_ref_some(),
882 name: name.to_string(),
883 expected_sha: String::new(),
884 actor: Some(self.context().actor()),
885 options: Some(self.context().write_options()),
886 force,
887 })
888 .await
889 .map_err(BabyError::from)?;
890 Ok(())
891 }
892
893 pub async fn rename_branch(&self, from: &str, to: &str) -> Result<(), BabyError> {
894 let mut client = self.context().ks.command();
895 client
896 .rename_branch(pb::RenameBranchRequest {
897 repository: self.repository_ref_some(),
898 name: from.to_string(),
899 new_name: to.to_string(),
900 actor: Some(self.context().actor()),
901 options: Some(self.context().write_options()),
902 })
903 .await
904 .map_err(BabyError::from)?;
905 Ok(())
906 }
907
908 pub async fn list_tags(&self, opts: ListTagsOptions) -> Result<Vec<TagInfo>, BabyError> {
909 let mut client = self.refs_client();
910 let mut token = String::new();
911 let mut out = Vec::new();
912 loop {
913 let response = client
914 .list_tags(pb::ListTagsRequest {
915 repository: self.repository_ref_some(),
916 query: opts.patterns.first().cloned().unwrap_or_default(),
917 sort: pb::TagSort::NameAsc as i32,
918 page: Some(page_request(200, token.clone())),
919 })
920 .await
921 .map_err(BabyError::from)?
922 .into_inner();
923 for tag in response.tags {
924 out.push(tag_from_view(tag));
925 }
926 let next = response
927 .page
928 .as_ref()
929 .map(|p| p.next_page_token.clone())
930 .unwrap_or_default();
931 if next.is_empty() {
932 break;
933 }
934 token = next;
935 }
936 Ok(out)
937 }
938
939 pub async fn find_tag(&self, name: &str) -> Result<TagInfo, BabyError> {
940 let mut client = self.refs_client();
941 let response = client
942 .get_tag(pb::GetTagRequest {
943 repository: self.repository_ref_some(),
944 name: name.to_string(),
945 })
946 .await
947 .map_err(BabyError::from)?
948 .into_inner();
949 response
950 .tag
951 .map(tag_from_view)
952 .ok_or_else(|| BabyError::TagNotFound(name.to_string()))
953 }
954
955 pub async fn tag_exists(&self, name: &str) -> Result<bool, BabyError> {
956 match self.find_tag(name).await {
957 Ok(_) => Ok(true),
958 Err(BabyError::TagNotFound(_)) => Ok(false),
959 Err(error) => Err(error),
960 }
961 }
962
963 pub async fn create_lightweight_tag(&self, opts: CreateTagOptions) -> Result<(), BabyError> {
964 self.create_tag(opts, false).await
965 }
966
967 pub async fn create_annotated_tag(&self, opts: CreateTagOptions) -> Result<(), BabyError> {
968 self.create_tag(opts, true).await
969 }
970
971 async fn create_tag(&self, opts: CreateTagOptions, annotated: bool) -> Result<(), BabyError> {
972 let mut client = self.context().ks.command();
973 client
974 .create_tag(pb::CreateTagRequest {
975 repository: self.repository_ref_some(),
976 name: opts.name,
977 target_sha: opts.target,
978 message: opts.message.unwrap_or_default(),
979 actor: Some(self.context().actor()),
980 options: Some(self.context().write_options()),
981 annotated,
982 })
983 .await
984 .map_err(BabyError::from)?;
985 Ok(())
986 }
987
988 pub async fn delete_tag(&self, name: &str) -> Result<(), BabyError> {
989 let mut client = self.context().ks.command();
990 client
991 .delete_tag(pb::DeleteTagRequest {
992 repository: self.repository_ref_some(),
993 name: name.to_string(),
994 expected_sha: String::new(),
995 actor: Some(self.context().actor()),
996 options: Some(self.context().write_options()),
997 })
998 .await
999 .map_err(BabyError::from)?;
1000 Ok(())
1001 }
1002
1003 pub async fn raw_diff(
1004 &self,
1005 req: &DiffRequest,
1006 _opts: Option<&DiffOptions>,
1007 ) -> Result<String, BabyError> {
1008 let mut client = self.diff_client();
1009 let mut stream = client
1010 .get_raw_diff(pb::GetRawDiffRequest {
1011 repository: self.repository_ref_some(),
1012 base_revision: req.left.to_string(),
1013 head_revision: req.right.to_string(),
1014 })
1015 .await
1016 .map_err(BabyError::from)?
1017 .into_inner();
1018 let mut out = Vec::new();
1019 while let Some(chunk) = stream.message().await.map_err(BabyError::from)? {
1020 out.extend_from_slice(&chunk.data);
1021 }
1022 Ok(String::from_utf8_lossy(&out).into_owned())
1023 }
1024
1025 pub async fn range_diff(
1026 &self,
1027 spec: &RangeDiffSpec,
1028 _opts: &DiffOptions,
1029 _hex_len: Option<u8>,
1030 ) -> Result<String, BabyError> {
1031 let (from, to, base) = match spec {
1032 RangeDiffSpec::Range(a, b) => (a.clone(), b.clone(), String::new()),
1033 RangeDiffSpec::RevisionRange(a, b) => (a.clone(), b.clone(), String::new()),
1034 RangeDiffSpec::BaseWithRevisions(base, a, b) => (a.clone(), b.clone(), base.clone()),
1035 };
1036 let mut client = self.diff_client();
1037 let mut stream = client
1038 .get_range_diff(pb::GetRangeDiffRequest {
1039 repository: self.repository_ref_some(),
1040 from,
1041 to,
1042 base,
1043 })
1044 .await
1045 .map_err(BabyError::from)?
1046 .into_inner();
1047 let mut out = Vec::new();
1048 while let Some(chunk) = stream.message().await.map_err(BabyError::from)? {
1049 out.extend_from_slice(&chunk.data);
1050 }
1051 Ok(String::from_utf8_lossy(&out).into_owned())
1052 }
1053
1054 pub async fn shortstat(
1055 &self,
1056 req: &DiffRequest,
1057 _opts: &DiffOptions,
1058 ) -> Result<ShortStat, BabyError> {
1059 let mut client = self.diff_client();
1060 let response = client
1061 .get_diff_short_stat(pb::GetDiffShortStatRequest {
1062 repository: self.repository_ref_some(),
1063 base_revision: req.left.to_string(),
1064 head_revision: req.right.to_string(),
1065 })
1066 .await
1067 .map_err(BabyError::from)?
1068 .into_inner();
1069 let stats = response.stats.unwrap_or_default();
1070 Ok(ShortStat {
1071 files_changed: stats.files_changed,
1072 additions: stats.additions,
1073 deletions: stats.deletions,
1074 })
1075 }
1076
1077 pub async fn diff_stats(
1078 &self,
1079 req: &DiffRequest,
1080 opts: &DiffOptions,
1081 ) -> Result<Vec<NumStat>, BabyError> {
1082 let mut client = self.diff_client();
1083 let response = client
1084 .list_diff_num_stats(pb::ListDiffNumStatsRequest {
1085 repository: self.repository_ref_some(),
1086 base_revision: req.left.to_string(),
1087 head_revision: req.right.to_string(),
1088 paths: opts
1089 .paths
1090 .as_ref()
1091 .map(|paths| {
1092 paths
1093 .iter()
1094 .map(|p| p.to_string_lossy().into_owned())
1095 .collect()
1096 })
1097 .unwrap_or_default(),
1098 })
1099 .await
1100 .map_err(BabyError::from)?
1101 .into_inner();
1102 Ok(response
1103 .stats
1104 .into_iter()
1105 .map(|stat| NumStat {
1106 additions: stat.additions,
1107 deletions: stat.deletions,
1108 path: PathBuf::from(stat.path),
1109 old_path: None,
1110 })
1111 .collect())
1112 }
1113
1114 pub async fn find_changed_paths(
1115 &self,
1116 requests: Vec<ChangedPathRequest>,
1117 _filter_status: Option<char>,
1118 _detect_renames: Option<u8>,
1119 _merge_parents: bool,
1120 ) -> Result<Vec<ChangedPath>, BabyError> {
1121 let mut client = self.diff_client();
1122 let (base, head, commit) = match requests.first() {
1123 Some(ChangedPathRequest::Commit(oid_value)) => {
1124 (String::new(), String::new(), oid_value.to_string())
1125 }
1126 Some(ChangedPathRequest::TreePair(left, right)) => {
1127 (left.to_string(), right.to_string(), String::new())
1128 }
1129 None => return Ok(Vec::new()),
1130 };
1131 let response = client
1132 .list_changed_paths(pb::ListChangedPathsRequest {
1133 repository: self.repository_ref_some(),
1134 base_revision: base,
1135 head_revision: head,
1136 commit_id: commit,
1137 })
1138 .await
1139 .map_err(BabyError::from)?
1140 .into_inner();
1141 Ok(response.paths.into_iter().map(changed_path).collect())
1142 }
1143
1144 pub async fn commit_diff(
1145 &self,
1146 req: &DiffRequest,
1147 _opts: &DiffOptions,
1148 _hex_len: Option<u8>,
1149 ) -> Result<Vec<ChangedPathDiff>, BabyError> {
1150 let mut client = self.diff_client();
1151 let response = client
1152 .compare_commits(pb::CompareCommitsRequest {
1153 repository: self.repository_ref_some(),
1154 base_revision: req.left.to_string(),
1155 head_revision: req.right.to_string(),
1156 merge_base: false,
1157 path: String::new(),
1158 page: None,
1159 })
1160 .await
1161 .map_err(BabyError::from)?
1162 .into_inner();
1163 Ok(response.files.into_iter().map(changed_path_diff).collect())
1164 }
1165
1166 pub async fn diff_trees(
1167 &self,
1168 base: &str,
1169 head: &str,
1170 _path: &Path,
1171 _opts: DiffTreesOptions,
1172 ) -> Result<Vec<TreeDiff>, BabyError> {
1173 let mut client = self.diff_client();
1174 let response = client
1175 .list_diff_tree(pb::ListDiffTreeRequest {
1176 repository: self.repository_ref_some(),
1177 base_revision: base.to_string(),
1178 head_revision: head.to_string(),
1179 use_merge_base: false,
1180 paths: Vec::new(),
1181 })
1182 .await
1183 .map_err(BabyError::from)?
1184 .into_inner();
1185 Ok(response
1186 .records
1187 .into_iter()
1188 .map(|record| TreeDiff {
1189 old_path: if record.base_path.is_empty() {
1190 None
1191 } else {
1192 Some(PathBuf::from(record.base_path))
1193 },
1194 new_path: if record.head_path.is_empty() {
1195 None
1196 } else {
1197 Some(PathBuf::from(record.head_path))
1198 },
1199 old_mode: Some(mode_of(&format!("{:o}", record.base_mode))),
1200 new_mode: Some(mode_of(&format!("{:o}", record.head_mode))),
1201 old_oid: oid_opt(&record.base_blob_id),
1202 new_oid: oid_opt(&record.head_blob_id),
1203 status: tree_diff_status(&record.status),
1204 })
1205 .collect())
1206 }
1207
1208 pub async fn list_conflict_files(
1209 &self,
1210 base: &str,
1211 head: &str,
1212 opts: &ListConflictOptions,
1213 ) -> Result<Vec<crate::rpcgit::conflict::types::ConflictFile>, BabyError> {
1214 let mut client = self.context().ks.conflict();
1215 let response = client
1216 .check_conflicts(pb::CheckConflictsRequest {
1217 repository: self.repository_ref_some(),
1218 base_revision: base.to_string(),
1219 head_revision: head.to_string(),
1220 ancestor_revision: String::new(),
1221 })
1222 .await
1223 .map_err(BabyError::from)?
1224 .into_inner();
1225 let our_commit_oid = oid(base)
1226 .unwrap_or_else(|_| oid_opt("0000000000000000000000000000000000000000").expect("zero"));
1227 let mut out = Vec::new();
1228 for path in response
1229 .status
1230 .map(|status| status.paths)
1231 .unwrap_or_default()
1232 {
1233 let content = if opts.skip_content {
1234 None
1235 } else {
1236 let mut client = self.context().ks.conflict();
1237 client
1238 .get_conflict_detail(pb::GetConflictDetailRequest {
1239 repository: self.repository_ref_some(),
1240 base_revision: base.to_string(),
1241 head_revision: head.to_string(),
1242 path: path.clone(),
1243 })
1244 .await
1245 .ok()
1246 .map(|response| response.into_inner().content)
1247 };
1248 out.push(crate::rpcgit::conflict::types::ConflictFile {
1249 header: crate::rpcgit::conflict::types::ConflictFileHeader {
1250 our_commit_oid: our_commit_oid.clone(),
1251 their_path: Some(PathBuf::from(&path)),
1252 our_path: Some(PathBuf::from(&path)),
1253 ancestor_path: None,
1254 our_mode: 0,
1255 },
1256 content,
1257 });
1258 }
1259 Ok(out)
1260 }
1261
1262 pub async fn diverging_commits(
1263 &self,
1264 base: &str,
1265 target: &str,
1266 ) -> Result<crate::rpcgit::compare::types::DivergeObject, BabyError> {
1267 let ahead = self
1268 .commit_ids_between_reverse(base, target, None, None)
1269 .await?
1270 .len() as u32;
1271 let behind = self
1272 .commit_ids_between_reverse(target, base, None, None)
1273 .await?
1274 .len() as u32;
1275 Ok(crate::rpcgit::compare::types::DivergeObject { ahead, behind })
1276 }
1277
1278 pub async fn commit_ids_between_reverse(
1279 &self,
1280 start: &str,
1281 end: &str,
1282 _limit: Option<u32>,
1283 max_count: Option<u32>,
1284 ) -> Result<Vec<ObjectId>, BabyError> {
1285 let commits = self
1286 .list_commits(ListCommitsOptions {
1287 revision: format!("{start}..{end}"),
1288 max_count,
1289 ..Default::default()
1290 })
1291 .await?;
1292 let mut ids: Vec<ObjectId> = commits.into_iter().map(|commit| commit.id).collect();
1293 ids.reverse();
1294 Ok(ids)
1295 }
1296
1297 pub async fn conflict_detail(
1298 &self,
1299 base: &str,
1300 head: &str,
1301 path: &str,
1302 ) -> Result<String, BabyError> {
1303 let mut client = self.context().ks.conflict();
1304 let response = client
1305 .get_conflict_detail(pb::GetConflictDetailRequest {
1306 repository: self.repository_ref_some(),
1307 base_revision: base.to_string(),
1308 head_revision: head.to_string(),
1309 path: path.to_string(),
1310 })
1311 .await
1312 .map_err(BabyError::from)?
1313 .into_inner();
1314 Ok(response.content)
1315 }
1316
1317 pub async fn list_remotes(&self) -> Result<Vec<(String, String)>, BabyError> {
1318 let mut client = self.context().ks.remote();
1319 let response = client
1320 .list_remotes(pb::ListRemotesRequest {
1321 repository: self.repository_ref_some(),
1322 })
1323 .await
1324 .map_err(BabyError::from)?
1325 .into_inner();
1326 Ok(response
1327 .remotes
1328 .into_iter()
1329 .map(|remote| (remote.name, remote.fetch_url))
1330 .collect())
1331 }
1332
1333 pub async fn add_remote(
1334 &self,
1335 name: &str,
1336 url: &str,
1337 _option: RemoteOption,
1338 ) -> Result<(), BabyError> {
1339 let mut client = self.context().ks.remote();
1340 client
1341 .upsert_remote(pb::UpsertRemoteRequest {
1342 repository: self.repository_ref_some(),
1343 name: name.to_string(),
1344 url: url.to_string(),
1345 actor: Some(self.context().actor()),
1346 options: Some(self.context().write_options()),
1347 })
1348 .await
1349 .map_err(BabyError::from)?;
1350 Ok(())
1351 }
1352
1353 pub async fn remove_remote(&self, name: &str) -> Result<(), BabyError> {
1354 let mut client = self.context().ks.remote();
1355 client
1356 .remove_remote(pb::RemoveRemoteRequest {
1357 repository: self.repository_ref_some(),
1358 name: name.to_string(),
1359 actor: Some(self.context().actor()),
1360 options: Some(self.context().write_options()),
1361 })
1362 .await
1363 .map_err(BabyError::from)?;
1364 Ok(())
1365 }
1366
1367 pub async fn get_remote_address(&self, name: &str) -> Result<Option<String>, BabyError> {
1368 Ok(self
1369 .list_remotes()
1370 .await?
1371 .into_iter()
1372 .find(|(remote, _)| remote == name)
1373 .map(|(_, url)| url))
1374 }
1375
1376 pub async fn create_archive(&self, req: CreateArchiveRequest) -> Result<(), BabyError> {
1377 let mut client = self.context().ks.archive();
1378 client
1379 .start_archive(pb::StartArchiveRequest {
1380 repository: self.repository_ref_some(),
1381 revision: req.commit,
1382 format: archive_format(req.format) as i32,
1383 path_filters: req.paths,
1384 actor: Some(self.context().actor()),
1385 idempotency_key: String::new(),
1386 })
1387 .await
1388 .map_err(BabyError::from)?;
1389 Ok(())
1390 }
1391
1392 pub async fn get_archive(&self, req: GetArchiveRequest) -> Result<ArchiveArtifact, BabyError> {
1393 let mut client = self.context().ks.archive();
1394 let response = client
1395 .get_archive_artifact(pb::GetArchiveArtifactRequest {
1396 repository: self.repository_ref_some(),
1397 revision: req.commit,
1398 format: archive_format(req.format) as i32,
1399 path_filters: req.exclude,
1400 })
1401 .await
1402 .map_err(BabyError::from)?
1403 .into_inner();
1404 let artifact = response
1405 .artifact
1406 .ok_or_else(|| BabyError::Custom("archive artifact missing".into()))?;
1407 Ok(ArchiveArtifact {
1408 format: artifact.format,
1409 size: artifact.size_bytes,
1410 storage_key: artifact.storage_key,
1411 download_url: artifact.download_url,
1412 })
1413 }
1414
1415 pub async fn create_bundle(&self, _req: BundleRequest) -> Result<(), BabyError> {
1416 let mut client = self.context().ks.archive();
1417 client
1418 .start_archive_bundle(pb::StartArchiveBundleRequest {
1419 repository: self.repository_ref_some(),
1420 actor: Some(self.context().actor()),
1421 idempotency_key: String::new(),
1422 })
1423 .await
1424 .map_err(BabyError::from)?;
1425 Ok(())
1426 }
1427
1428 pub async fn init_repository(
1429 &self,
1430 _path: &str,
1431 _bare: bool,
1432 _format: Option<ObjectFormat>,
1433 ) -> Result<(), BabyError> {
1434 let mut client = self.context().ks.command();
1435 client
1436 .create_repository(pb::CreateRepositoryRequest {
1437 repository: self.repository_ref_some(),
1438 default_branch: self.context().model.default_branch.clone(),
1439 actor: Some(self.context().actor()),
1440 options: Some(self.context().write_options()),
1441 })
1442 .await
1443 .map_err(BabyError::from)?;
1444 Ok(())
1445 }
1446
1447 pub async fn repo_size(&self) -> Result<u64, BabyError> {
1448 let mut client = self.context().ks.repository();
1449 let response = client
1450 .get_repository_size(pb::GetRepositorySizeRequest {
1451 repository: self.repository_ref_some(),
1452 })
1453 .await
1454 .map_err(BabyError::from)?
1455 .into_inner();
1456 Ok(response.size_bytes)
1457 }
1458
1459 pub async fn merge_base(&self, _one: &str, _two: &str) -> Result<ObjectId, BabyError> {
1460 Err(unsupported("merge base"))
1461 }
1462
1463 pub async fn merge_tree(
1464 &self,
1465 _ours: &str,
1466 _theirs: &str,
1467 _base: Option<&str>,
1468 _force: bool,
1469 ) -> Result<crate::rpcgit::conflict::types::MergeTreeResult, BabyError> {
1470 Err(unsupported("merge tree"))
1471 }
1472
1473 pub async fn fast_import(
1474 &self,
1475 _path: &Path,
1476 _commits: Vec<FastImportCommit>,
1477 ) -> Result<(), BabyError> {
1478 Err(unsupported("fast-import"))
1479 }
1480
1481 pub async fn config_set(&self, _key: &str, _value: &str) -> Result<(), BabyError> {
1482 Ok(())
1483 }
1484
1485 pub async fn verify_tag_signature(&self, _name: &str) -> Result<TagVerifyResult, BabyError> {
1486 Err(unsupported("tag signature verification"))
1487 }
1488
1489 pub async fn list_submodules(
1490 &self,
1491 _commit: &str,
1492 ) -> Result<Vec<crate::rpcgit::submodule::types::SubModule>, BabyError> {
1493 Err(unsupported("submodules"))
1494 }
1495
1496 pub async fn prune(&self) -> Result<(), BabyError> {
1497 Err(unsupported("prune"))
1498 }
1499
1500 pub async fn update_remote_mirror(
1501 &self,
1502 _req: UpdateRemoteMirrorRequest,
1503 ) -> Result<crate::rpcgit::remote::types::UpdateRemoteMirrorResult, BabyError> {
1504 Err(unsupported("remote mirror update"))
1505 }
1506
1507 pub async fn find_remote_repository(&self, _url: &str) -> Result<bool, BabyError> {
1508 Ok(true)
1509 }
1510
1511 pub async fn config_get(&self, _key: &str) -> Result<Option<String>, BabyError> {
1512 Ok(None)
1513 }
1514}
1515
1516#[derive(Debug, Clone)]
1517pub struct ArchiveArtifact {
1518 pub format: i32,
1519 pub size: u64,
1520 pub storage_key: String,
1521 pub download_url: String,
1522}
1523
1524fn tag_from_view(tag: pb::TagView) -> TagInfo {
1525 let target = oid(&tag.target_sha)
1526 .unwrap_or_else(|_| oid_opt("0000000000000000000000000000000000000000").expect("zero"));
1527 TagInfo {
1528 name: tag.name,
1529 sha: target.clone(),
1530 target_sha: target,
1531 tag_type: if tag.annotated {
1532 TagType::Annotated
1533 } else {
1534 TagType::Lightweight
1535 },
1536 tagger: if tag.tagger_name.is_empty() && tag.tagger_email.is_empty() {
1537 None
1538 } else {
1539 Some(Signature {
1540 name: tag.tagger_name,
1541 email: tag.tagger_email,
1542 time: tag
1543 .created_at
1544 .and_then(|at| time::OffsetDateTime::from_unix_timestamp(at.seconds).ok())
1545 .unwrap_or(time::OffsetDateTime::UNIX_EPOCH),
1546 })
1547 },
1548 message: if tag.message.is_empty() {
1549 None
1550 } else {
1551 Some(tag.message)
1552 },
1553 }
1554}
1555
1556fn tree_diff_status(text: &str) -> TreeDiffStatus {
1557 match text {
1558 "A" | "added" | "Added" => TreeDiffStatus::Added,
1559 "D" | "deleted" | "Deleted" => TreeDiffStatus::Deleted,
1560 "T" | "type_changed" | "TypeChanged" => TreeDiffStatus::TypeChanged,
1561 "R" | "renamed" | "Renamed" => TreeDiffStatus::Renamed,
1562 "C" | "copied" | "Copied" => TreeDiffStatus::Copied,
1563 _ => TreeDiffStatus::Modified,
1564 }
1565}
1566
1567fn changed_path_diff(file: pb::DiffFileView) -> ChangedPathDiff {
1568 let mut patch = String::new();
1569 for hunk in &file.hunks {
1570 patch.push_str(&hunk.header);
1571 patch.push('\n');
1572 for line in &hunk.lines {
1573 let prefix =
1574 match pb::DiffLineKind::try_from(line.kind).unwrap_or(pb::DiffLineKind::Context) {
1575 pb::DiffLineKind::Addition => "+",
1576 pb::DiffLineKind::Deletion => "-",
1577 pb::DiffLineKind::NoNewline => "\\",
1578 _ => " ",
1579 };
1580 patch.push_str(prefix);
1581 patch.push_str(&line.content);
1582 patch.push('\n');
1583 }
1584 }
1585 let status =
1586 match pb::DiffFileStatus::try_from(file.status).unwrap_or(pb::DiffFileStatus::Modified) {
1587 pb::DiffFileStatus::Added => DiffStatus::Added,
1588 pb::DiffFileStatus::Deleted => DiffStatus::Deleted,
1589 pb::DiffFileStatus::Renamed => DiffStatus::Renamed,
1590 pb::DiffFileStatus::Copied => DiffStatus::Copied,
1591 pb::DiffFileStatus::TypeChanged => DiffStatus::TypeChanged,
1592 _ => DiffStatus::Modified,
1593 };
1594 let old_path = if file.old_path.is_empty() {
1595 None
1596 } else {
1597 Some(PathBuf::from(file.old_path))
1598 };
1599 let new_path = if file.new_path.is_empty() {
1600 old_path.clone().unwrap_or_default()
1601 } else {
1602 PathBuf::from(file.new_path)
1603 };
1604 ChangedPathDiff {
1605 path: ChangedPath {
1606 old_path,
1607 new_path,
1608 old_mode: 0,
1609 new_mode: 0,
1610 old_oid: oid_opt(&file.old_oid),
1611 new_oid: oid_opt(&file.new_oid),
1612 status,
1613 },
1614 lines_added: file.additions as u64,
1615 lines_removed: file.deletions as u64,
1616 binary: file.binary,
1617 patch: if patch.is_empty() || file.binary {
1618 None
1619 } else {
1620 Some(patch.into_bytes())
1621 },
1622 }
1623}
1624
1625fn archive_format(format: ArchiveFormat) -> pb::ArchiveFormat {
1626 match format {
1627 ArchiveFormat::Tar => pb::ArchiveFormat::Tar,
1628 ArchiveFormat::TarGz => pb::ArchiveFormat::TarGz,
1629 ArchiveFormat::TarBz2 => pb::ArchiveFormat::TarBz2,
1630 ArchiveFormat::Zip => pb::ArchiveFormat::Zip,
1631 }
1632}
1633
1634impl From<tonic::Status> for BabyError {
1635 fn from(status: tonic::Status) -> Self {
1636 match status.code() {
1637 tonic::Code::NotFound => BabyError::RevisionNotFound(status.message().to_string()),
1638 tonic::Code::AlreadyExists => BabyError::Custom(status.message().to_string()),
1639 tonic::Code::Aborted | tonic::Code::FailedPrecondition => {
1640 BabyError::RefUpdateRejected(status.message().to_string())
1641 }
1642 tonic::Code::InvalidArgument => {
1643 BabyError::RevisionInvalid(status.message().to_string())
1644 }
1645 tonic::Code::Unauthenticated => BabyError::Custom("unauthenticated".to_string()),
1646 tonic::Code::PermissionDenied => BabyError::Custom("permission denied".to_string()),
1647 tonic::Code::Unimplemented => BabyError::Custom(status.message().to_string()),
1648 _ => BabyError::Custom(format!("{}: {}", status.code(), status.message())),
1649 }
1650 }
1651}
Renamed lib/gitbaby/src/command/error.rs → lib/git/src/rpcgit/cmd_error.rs View lib/git/src/rpcgit/cmd_error.rs

No content changes.

Renamed lib/gitbaby/src/error.rs → lib/git/src/rpcgit/error.rs View lib/git/src/rpcgit/error.rs
@@ -1,7 +1,7 @@
11use std::fmt;
22use std::path::PathBuf;
33
4use crate::command::error::CmdError;
4use crate::rpcgit::cmd_error::CmdError;
55
66#[derive(Debug)]
77pub enum BabyError {
@@ -101,7 +101,7 @@
101101 },
102102 Cache {
103103 op: &'static str,
104 source: foyer::Error,
104 source: String,
105105 },
106106}
107107
@@ -262,7 +262,7 @@
262262 Self::Command(err) => Some(err),
263263 Self::Io { source, .. } => Some(source),
264264 Self::Spawn { source, .. } => Some(source),
265 Self::Cache { source, .. } => Some(source),
265 Self::Cache { .. } => None,
266266 Self::AmbiguousRef { .. } | Self::TagVerifyFailed { .. } => None,
267267 _ => None,
268268 }
Added lib/git/src/rpcgit/mod.rs View lib/git/src/rpcgit/mod.rs
@@ -0,0 +1,56 @@
1pub mod archive;
2pub mod blame;
3pub mod blob;
4pub mod branch;
5pub mod cmd_error;
6pub mod commit;
7pub mod compare;
8pub mod conflict;
9pub mod diff;
10pub mod error;
11pub mod oid;
12pub mod refs;
13pub mod remote;
14pub mod setup;
15pub mod submodule;
16pub mod tags;
17pub mod tree;
18
19mod api;
20
21use std::sync::Arc;
22
23use crate::context::facade::GitContext;
24
25#[derive(Clone)]
26pub struct GitBaby {
27 ctx: Arc<GitContext>,
28}
29
30impl GitBaby {
31 pub fn new(ctx: Arc<GitContext>) -> Self {
32 Self { ctx }
33 }
34
35 pub fn context(&self) -> &GitContext {
36 &self.ctx
37 }
38}
39
40pub mod gix {
41 pub mod hash {
42 pub use crate::rpcgit::oid::ObjectId;
43 }
44
45 pub use hash::ObjectId;
46
47 pub mod objs {
48 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
49 pub enum Kind {
50 Blob,
51 Tree,
52 Commit,
53 Tag,
54 }
55 }
56}
Added lib/git/src/rpcgit/oid.rs View lib/git/src/rpcgit/oid.rs
@@ -0,0 +1,80 @@
1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub struct InvalidOid;
7
8impl fmt::Display for InvalidOid {
9 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
10 f.write_str("invalid object id")
11 }
12}
13
14impl std::error::Error for InvalidOid {}
15
16#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
17#[serde(transparent)]
18pub struct ObjectId(Box<str>);
19
20impl ObjectId {
21 pub fn from_hex(bytes: &[u8]) -> Result<Self, InvalidOid> {
22 let text = std::str::from_utf8(bytes).map_err(|_| InvalidOid)?;
23 Self::parse(text)
24 }
25
26 pub fn parse(text: &str) -> Result<Self, InvalidOid> {
27 let text = text.trim();
28 if !matches!(text.len(), 40 | 64) || !text.bytes().all(|b| b.is_ascii_hexdigit()) {
29 return Err(InvalidOid);
30 }
31 Ok(Self(text.to_ascii_lowercase().into_boxed_str()))
32 }
33
34 pub fn from_bytes_or_panic(bytes: &[u8]) -> Self {
35 Self::from_hex(bytes).expect("invalid object id")
36 }
37
38 pub fn as_bytes(&self) -> &[u8] {
39 self.0.as_bytes()
40 }
41
42 pub fn to_hex(&self) -> &str {
43 &self.0
44 }
45
46 pub fn as_str(&self) -> &str {
47 &self.0
48 }
49
50 pub fn short(&self, len: usize) -> &str {
51 let end = len.min(self.0.len());
52 &self.0[..end]
53 }
54}
55
56impl fmt::Display for ObjectId {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 f.write_str(&self.0)
59 }
60}
61
62impl fmt::Debug for ObjectId {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 write!(f, "ObjectId({})", self.0)
65 }
66}
67
68impl std::str::FromStr for ObjectId {
69 type Err = InvalidOid;
70
71 fn from_str(s: &str) -> Result<Self, Self::Err> {
72 Self::parse(s)
73 }
74}
75
76impl AsRef<str> for ObjectId {
77 fn as_ref(&self) -> &str {
78 &self.0
79 }
80}
Modified lib/git/src/service/analysis.rs View lib/git/src/service/analysis.rs
@@ -1,7 +1,7 @@
11use std::collections::HashMap;
22
3use gitbaby::commit::types::TreeEntryMode;
4use gitbaby::error::BabyError;
3use crate::rpcgit::commit::types::TreeEntryMode;
4use crate::rpcgit::error::BabyError;
55use models::git::git_languages;
66use models::repo::repos;
77use sea_orm::{
@@ -309,7 +309,7 @@
309309}
310310
311311async fn resolve_analysis_revision(
312 baby: &gitbaby::GitBaby,
312 baby: &crate::rpcgit::GitBaby,
313313 preferred: Option<&str>,
314314 default_branch: &str,
315315) -> Result<Option<String>, GitError> {
@@ -335,21 +335,21 @@
335335 }
336336 }
337337 let branches = baby
338 .list_branches(gitbaby::branch::ListBranchesOptions::default())
338 .list_branches(crate::rpcgit::branch::ListBranchesOptions::default())
339339 .await
340340 .map_err(map_baby)?;
341341 Ok(branches.into_iter().next().map(|b| b.sha.to_string()))
342342}
343343
344344pub(crate) async fn language_stats(
345 baby: &gitbaby::GitBaby,
345 baby: &crate::rpcgit::GitBaby,
346346 revision: &str,
347347) -> Result<HashMap<String, i64>, GitError> {
348348 let entries = baby
349349 .walk_tree(
350350 revision,
351351 std::path::Path::new(""),
352 gitbaby::tree::types::WalkTreeOptions {
352 crate::rpcgit::tree::types::WalkTreeOptions {
353353 recurse: true,
354354 show_trees: false,
355355 },
Modified lib/git/src/service/archives.rs View lib/git/src/service/archives.rs
@@ -1,14 +1,11 @@
1use std::path::Path;
2
3use export::storage::AppStorage;
4use gitbaby::archive::types::{ArchiveFormat, CreateArchiveRequest};
1use kenshell::proto::v1 as pb;
52use serde::{Deserialize, Serialize};
6use time::OffsetDateTime;
3use uuid::Uuid;
74
5use crate::rpcgit::archive::types::ArchiveFormat;
86use crate::service::GitService;
9use crate::service::cache::sanitize_key;
10use crate::service::gitcmd::{run_git, run_git_capped};
11use crate::share::errors::{GitError, map_baby};
7use crate::service::rpc::{rpc_error, wait_job};
8use crate::share::errors::GitError;
129use crate::share::validate::validate_revision;
1310
1411#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
@@ -26,8 +23,8 @@
2623 pub path_filter: Option<String>,
2724}
2825
29fn fmt_ext(f: ArchiveFormat) -> &'static str {
30 match f {
26fn ext(format: ArchiveFormat) -> &'static str {
27 match format {
3128 ArchiveFormat::Tar => "tar",
3229 ArchiveFormat::TarGz => "tar.gz",
3330 ArchiveFormat::TarBz2 => "tar.bz2",
@@ -35,6 +32,15 @@
3532 }
3633}
3734
35fn archive_proto(format: ArchiveFormat) -> pb::ArchiveFormat {
36 match format {
37 ArchiveFormat::Tar => pb::ArchiveFormat::Tar,
38 ArchiveFormat::TarGz => pb::ArchiveFormat::TarGz,
39 ArchiveFormat::TarBz2 => pb::ArchiveFormat::TarBz2,
40 ArchiveFormat::Zip => pb::ArchiveFormat::Zip,
41 }
42}
43
3844impl GitService {
3945 pub async fn archive_create(
4046 &self,
@@ -45,40 +51,35 @@
4551 ) -> Result<ArchiveResultDto, GitError> {
4652 validate_revision(&revision)?;
4753 let ctx = self.context(space_name, repo_name).await?;
48 let baby = self.baby(&ctx);
49 let ext = fmt_ext(opts.format);
50 let prefix_filter = opts.path_filter.clone();
51 let req = CreateArchiveRequest {
52 commit: revision.clone(),
53 format: opts.format,
54 prefix: Some(format!("{}-", ctx.model.id.simple())),
55 paths: prefix_filter.into_iter().collect(),
56 };
57 baby.create_archive(req).await.map_err(map_baby)?;
58
59 let key = format!(
60 "git/{}/{}.{}",
61 ctx.model.id.simple(),
62 sanitize_key(&revision),
63 ext
64 );
65 let out_dir = ctx.repo_dir();
66 let storage: &AppStorage = &self.state.storage;
67 let timeout = self.state.config.git.git_command_timeout_secs;
68 let bytes = read_archive_bytes(&out_dir, &revision, ext, timeout).await?;
69 let bytes_len = bytes.len() as i64;
70 storage
71 .client()
72 .put(&key, &bytes)
54 let mut client = ctx.ks.archive();
55 let job = client
56 .start_archive(pb::StartArchiveRequest {
57 repository: Some(ctx.repository_ref()),
58 revision,
59 format: archive_proto(opts.format) as i32,
60 path_filters: opts.path_filter.into_iter().collect(),
61 actor: Some(ctx.actor()),
62 idempotency_key: Uuid::now_v7().to_string(),
63 })
7364 .await
74 .map_err(|e| GitError::Internal(format!("archive put failed: {e}")))?;
75 let download_url = storage.client().presign_get(&key, 3600).ok();
76
65 .map_err(rpc_error)?
66 .into_inner()
67 .job
68 .ok_or_else(|| GitError::Internal("archive job missing".into()))?;
69 let job = wait_job(&ctx.ks, &job.id, self.job_timeout()).await?;
70 let artifact = match job.result {
71 Some(pb::job::Result::Archive(archive)) => archive,
72 _ => {
73 return Err(GitError::Internal(
74 "archive job returned no artifact".into(),
75 ));
76 }
77 };
7778 Ok(ArchiveResultDto {
78 format: ext.to_string(),
79 size: bytes_len,
80 cache_key: key,
81 download_url,
79 format: ext(opts.format).to_string(),
80 size: artifact.size_bytes as i64,
81 cache_key: artifact.storage_key,
82 download_url: Some(artifact.download_url),
8283 })
8384 }
8485
@@ -91,28 +92,24 @@
9192 ) -> Result<ArchiveResultDto, GitError> {
9293 validate_revision(&revision)?;
9394 let ctx = self.context(space_name, repo_name).await?;
94 let ext = fmt_ext(opts.format);
95 let key = format!(
96 "git/{}/{}.{}",
97 ctx.model.id.simple(),
98 sanitize_key(&revision),
99 ext
100 );
101 let storage: &AppStorage = &self.state.storage;
102 let head = storage
103 .client()
104 .head(&key)
95 let mut client = ctx.ks.archive();
96 let artifact = client
97 .get_archive_artifact(pb::GetArchiveArtifactRequest {
98 repository: Some(ctx.repository_ref()),
99 revision,
100 format: archive_proto(opts.format) as i32,
101 path_filters: opts.path_filter.into_iter().collect(),
102 })
105103 .await
106 .map_err(|e| GitError::Internal(format!("storage head: {e}")))?;
107 let Some((size, _)) = head else {
108 return Err(GitError::NotFound);
109 };
110 let download_url = storage.client().presign_get(&key, 3600).ok();
104 .map_err(rpc_error)?
105 .into_inner()
106 .artifact
107 .ok_or(GitError::NotFound)?;
111108 Ok(ArchiveResultDto {
112 format: ext.to_string(),
113 size: size as i64,
114 cache_key: key,
115 download_url,
109 format: ext(opts.format).to_string(),
110 size: artifact.size_bytes as i64,
111 cache_key: artifact.storage_key,
112 download_url: Some(artifact.download_url),
116113 })
117114 }
118115
@@ -122,71 +119,32 @@
122119 repo_name: String,
123120 ) -> Result<ArchiveResultDto, GitError> {
124121 let ctx = self.context(space_name, repo_name).await?;
125 let bundle_path = std::env::temp_dir().join(format!(
126 "gitdata-bundle-{}-{}",
127 std::process::id(),
128 OffsetDateTime::now_utc().unix_timestamp_nanos()
129 ));
130 let timeout = self.state.config.git.git_command_timeout_secs;
131 run_git(
132 "archives::bundle",
133 &ctx.repo_dir(),
134 vec![
135 "bundle".to_string(),
136 "create".to_string(),
137 bundle_path.to_string_lossy().into_owned(),
138 "--all".to_string(),
139 ],
140 &[],
141 timeout,
142 )
143 .await?;
144 let key = format!("git/{}/bundle.git", ctx.model.id.simple());
145 let storage: &AppStorage = &self.state.storage;
146 let bytes = tokio::task::spawn_blocking({
147 let p = bundle_path.clone();
148 move || std::fs::read(&p)
149 })
150 .await
151 .map_err(|e| GitError::Internal(format!("bundle read task: {e}")))?
152 .map_err(|e| GitError::Internal(format!("bundle read: {e}")))?;
153 let bytes_len = bytes.len() as i64;
154 storage
155 .client()
156 .put(&key, &bytes)
122 let mut client = ctx.ks.archive();
123 let job = client
124 .start_archive_bundle(pb::StartArchiveBundleRequest {
125 repository: Some(ctx.repository_ref()),
126 actor: Some(ctx.actor()),
127 idempotency_key: Uuid::now_v7().to_string(),
128 })
157129 .await
158 .map_err(|e| GitError::Internal(format!("bundle put failed: {e}")))?;
159 let download_url = storage.client().presign_get(&key, 3600).ok();
160 let _ = std::fs::remove_file(&bundle_path);
130 .map_err(rpc_error)?
131 .into_inner()
132 .job
133 .ok_or_else(|| GitError::Internal("bundle job missing".into()))?;
134 let job = wait_job(&ctx.ks, &job.id, self.job_timeout()).await?;
135 let artifact = match job.result {
136 Some(pb::job::Result::Archive(archive)) => archive,
137 _ => {
138 return Err(GitError::Internal(
139 "archive job returned no artifact".into(),
140 ));
141 }
142 };
161143 Ok(ArchiveResultDto {
162144 format: "bundle".to_string(),
163 size: bytes_len,
164 cache_key: key,
165 download_url,
145 size: artifact.size_bytes as i64,
146 cache_key: artifact.storage_key,
147 download_url: Some(artifact.download_url),
166148 })
167149 }
168}
169
170async fn read_archive_bytes(
171 dir: &Path,
172 rev: &str,
173 ext: &str,
174 timeout_secs: u64,
175) -> Result<Vec<u8>, GitError> {
176 let cap = 512 * 1024 * 1024;
177 let out = run_git_capped(
178 "archives::read",
179 dir,
180 vec![
181 "archive".to_string(),
182 "--format".to_string(),
183 ext.to_string(),
184 rev.to_string(),
185 ],
186 &[],
187 timeout_secs,
188 cap,
189 )
190 .await?;
191 Ok(out.stdout)
192150}
Modified lib/git/src/service/blame.rs View lib/git/src/service/blame.rs
@@ -1,4 +1,4 @@
1use gitbaby::blame::types::{BlameHunk, BlameOptions, Range};
1use crate::rpcgit::blame::types::{BlameHunk, BlameOptions, Range};
22use serde::{Deserialize, Serialize};
33
44use crate::service::GitService;
Modified lib/git/src/service/blobs.rs View lib/git/src/service/blobs.rs
@@ -1,4 +1,4 @@
1use gix::ObjectId;
1use crate::rpcgit::oid::ObjectId;
22use serde::{Deserialize, Serialize};
33
44use crate::service::GitService;
@@ -77,11 +77,11 @@
7777 oid: &ObjectId,
7878 ) -> Result<(i64, Vec<u8>), GitError> {
7979 let baby = self.baby(ctx);
80 let size = baby.get_blob_size(*oid).await.map_err(map_baby)?;
80 let size = baby.get_blob_size(oid.clone()).await.map_err(map_baby)?;
8181 let blob = baby
8282 .get_blob(
83 *oid,
84 gitbaby::blob::types::GetBlobOptions {
83 oid.clone(),
84 crate::rpcgit::blob::types::GetBlobOptions {
8585 limit: Some(SNIFF_LIMIT),
8686 },
8787 )
@@ -128,7 +128,10 @@
128128 );
129129 cache::cached_fetch(&ctx.cache, &key, IMMUTABLE_TTL, || async {
130130 let baby = self.baby(&ctx);
131 let size = baby.get_blob_size(object_id).await.map_err(map_baby)?;
131 let size = baby
132 .get_blob_size(object_id.clone())
133 .await
134 .map_err(map_baby)?;
132135 let truncated = size > max_blob as i64;
133136 let limit = if truncated {
134137 Some(max_blob as i64)
@@ -136,7 +139,10 @@
136139 None
137140 };
138141 let blob = baby
139 .get_blob(object_id, gitbaby::blob::types::GetBlobOptions { limit })
142 .get_blob(
143 object_id.clone(),
144 crate::rpcgit::blob::types::GetBlobOptions { limit },
145 )
140146 .await
141147 .map_err(map_baby)?;
142148 Ok(BlobContentDto {
@@ -180,7 +186,7 @@
180186 let ctx = self.context(space_name, repo_name).await?;
181187 let baby = self.baby(&ctx);
182188 let pointers = baby
183 .list_lfs_pointers(gitbaby::blob::types::ListLFSPointersOptions {
189 .list_lfs_pointers(crate::rpcgit::blob::types::ListLFSPointersOptions {
184190 revisions: vec![revision],
185191 limit,
186192 })
Modified lib/git/src/service/branches.rs View lib/git/src/service/branches.rs
@@ -1,6 +1,6 @@
11use std::collections::HashMap;
22
3use gitbaby::branch::ListBranchesOptions;
3use crate::rpcgit::branch::ListBranchesOptions;
44use models::git::git_branches;
55use models::repo::repos;
66use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, Unchanged};
Modified lib/git/src/service/clone.rs View lib/git/src/service/clone.rs
@@ -1,6 +1,3 @@
1use std::path::Path;
2
3use crate::service::gitcmd::run_git;
41use crate::share::errors::GitError;
52
63#[derive(Debug, Clone)]
@@ -19,76 +16,14 @@
1916 }
2017}
2118
22const ASKPASS_SCRIPT: &str = r#"#!/bin/sh
23case "$1" in
24 *sername*) printf '%s\n' "$GITDATA_GIT_USER" ;;
25 *) printf '%s\n' "$GITDATA_GIT_PASS" ;;
26esac
27"#;
28
29const MIRROR_REFSPECS: [&str; 2] = ["+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"];
30
31fn mirror_args(url: &str) -> Vec<String> {
32 let mut args = vec![
33 "fetch".to_string(),
34 "--prune".to_string(),
35 "--force".to_string(),
36 "--".to_string(),
37 url.to_string(),
38 ];
39 args.extend(MIRROR_REFSPECS.iter().map(|spec| (*spec).to_string()));
40 args
41}
42
4319pub async fn mirror_fetch(
44 label: &str,
45 dir: &Path,
46 url: &str,
47 credential: Option<&GitCredential>,
48 timeout_secs: u64,
20 _label: &str,
21 _dir: &std::path::Path,
22 _url: &str,
23 _credential: Option<&GitCredential>,
24 _timeout_secs: u64,
4925) -> Result<(), GitError> {
50 let Some(cred) = credential else {
51 run_git(label, dir, mirror_args(url), &[], timeout_secs).await?;
52 return Ok(());
53 };
54
55 let script = std::env::temp_dir().join(format!("gitdata-askpass-{}.sh", uuid::Uuid::now_v7()));
56 tokio::fs::write(&script, ASKPASS_SCRIPT)
57 .await
58 .map_err(|e| GitError::Internal(format!("write askpass script: {e}")))?;
59 if let Err(e) = set_executable(&script).await {
60 let _ = tokio::fs::remove_file(&script).await;
61 return Err(e);
62 }
63
64 let env = vec![
65 (
66 "GIT_ASKPASS".to_string(),
67 script.to_string_lossy().to_string(),
68 ),
69 ("GITDATA_GIT_USER".to_string(), cred.username.clone()),
70 ("GITDATA_GIT_PASS".to_string(), cred.secret.clone()),
71 ("GIT_TERMINAL_PROMPT".to_string(), "0".to_string()),
72 ];
73 let result = run_git(label, dir, mirror_args(url), &env, timeout_secs).await;
74 if let Err(e) = tokio::fs::remove_file(&script).await {
75 tracing::warn!(error = %e, "failed to remove askpass script");
76 }
77 result?;
78 Ok(())
79}
80
81#[cfg(unix)]
82async fn set_executable(path: &Path) -> Result<(), GitError> {
83 use std::os::unix::fs::PermissionsExt;
84 let perms = std::fs::Permissions::from_mode(0o700);
85 tokio::fs::set_permissions(path, perms)
86 .await
87 .map_err(|e| GitError::Internal(format!("chmod askpass script: {e}")))?;
88 Ok(())
89}
90
91#[cfg(not(unix))]
92async fn set_executable(_path: &Path) -> Result<(), GitError> {
93 Ok(())
26 Err(GitError::Unimplemented(
27 "mirror fetch is served by KenShell RemoteService",
28 ))
9429}
Modified lib/git/src/service/commits.rs View lib/git/src/service/commits.rs
@@ -1,6 +1,6 @@
11use std::path::PathBuf;
22
3use gitbaby::commit::types::{CommitInfo, CommitStats, ListCommitsOptions, TreeEntry};
3use crate::rpcgit::commit::types::{CommitInfo, CommitStats, ListCommitsOptions, TreeEntry};
44use models::git::git_commit_status;
55use models::git::git_commit_status::CommitStatusState;
66use models::git::git_commits;
Modified lib/git/src/service/conflicts.rs View lib/git/src/service/conflicts.rs
@@ -1,4 +1,4 @@
1use gitbaby::conflict::types::{ConflictFile, ListConflictOptions};
1use crate::rpcgit::conflict::types::{ConflictFile, ListConflictOptions};
22use serde::{Deserialize, Serialize};
33
44use crate::service::GitService;
Modified lib/git/src/service/content.rs View lib/git/src/service/content.rs
@@ -1,18 +1,8 @@
1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use gitbaby::repo::RepositoryFacade;
5use gix::actor::Signature;
6use gix::bstr::BString;
7use gix::date::Time;
8use gix::objs::Commit;
9use gix::objs::Write;
10use gix::{ObjectId, objs::Kind};
111use serde::{Deserialize, Serialize};
122use time::OffsetDateTime;
133
144use crate::service::GitService;
15use crate::share::errors::{GitError, map_baby};
5use crate::share::errors::GitError;
166use crate::share::identity::load_identity;
177
188#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -41,17 +31,6 @@
4131 pub id: String,
4232}
4333
44fn gix_signature(sig: &SignatureDto, offset_seconds: i32) -> Signature {
45 Signature {
46 name: BString::from(sig.name.clone()),
47 email: BString::from(sig.email.clone()),
48 time: Time {
49 seconds: sig.when.unix_timestamp(),
50 offset: offset_seconds,
51 },
52 }
53}
54
5534impl GitService {
5635 pub async fn caller_signature(
5736 &self,
@@ -66,187 +45,42 @@
6645 })
6746 }
6847
69 /// Write a blob object (no ref update).
7048 pub async fn write_blob(
7149 &self,
72 space_name: String,
73 repo_name: String,
74 data: Vec<u8>,
50 _space_name: String,
51 _repo_name: String,
52 _data: Vec<u8>,
7553 ) -> Result<WriteObjectResultDto, GitError> {
76 if data.len() as u64 > self.state.config.git.max_blob_size {
77 return Err(GitError::InvalidArgument(
78 "blob exceeds max size".to_string(),
79 ));
80 }
81 let ctx = self.context(space_name, repo_name).await?;
82 let repo = ctx.gix_repo().await.map_err(GitError::from)?;
83 let id = repo
84 .write_blob(data)
85 .map_err(|e| GitError::Internal(format!("write blob failed: {e}")))?;
86 Ok(WriteObjectResultDto { id: id.to_string() })
54 Err(GitError::Unimplemented(
55 "writing git objects requires a KenShell ContentWriteService",
56 ))
8757 }
8858
89 /// Build a tree object from flat file entries (recursive dirs created
90 /// implicitly). Returns the new tree id without touching any ref.
9159 pub async fn write_tree_from_files(
9260 &self,
93 space_name: String,
94 repo_name: String,
95 files: Vec<FileEntryDto>,
61 _space_name: String,
62 _repo_name: String,
63 _files: Vec<FileEntryDto>,
9664 ) -> Result<WriteObjectResultDto, GitError> {
97 let ctx = self.context(space_name, repo_name).await?;
98 let repo = ctx.gix_repo().await.map_err(GitError::from)?;
99
100 let mut blobs: BTreeMap<String, ObjectId> = BTreeMap::new();
101 for f in &files {
102 let path = clean_rel_path(&f.path)?;
103 let id = repo
104 .write_blob(&f.content)
105 .map_err(|e| GitError::Internal(format!("write blob `{path}` failed: {e}")))?;
106 blobs.insert(path, id.into());
107 }
108 let tree_oid = build_tree_recursive(&repo, "", &blobs)?;
109 Ok(WriteObjectResultDto {
110 id: tree_oid.to_string(),
111 })
65 Err(GitError::Unimplemented(
66 "writing git trees requires a KenShell ContentWriteService",
67 ))
11268 }
11369
114 /// Write a commit object (merge = arbitrary parents, squash = one
115 /// parent). Does not move any ref or HEAD.
11670 #[allow(clippy::too_many_arguments)]
11771 pub async fn commit_object(
11872 &self,
119 space_name: String,
120 repo_name: String,
121 kind: ObjectKind,
122 parents: Vec<String>,
123 tree: String,
124 message: String,
125 author: SignatureDto,
126 committer: SignatureDto,
73 _space_name: String,
74 _repo_name: String,
75 _kind: ObjectKind,
76 _parents: Vec<String>,
77 _tree: String,
78 _message: String,
79 _author: SignatureDto,
80 _committer: SignatureDto,
12781 ) -> Result<WriteObjectResultDto, GitError> {
128 if parents.is_empty() {
129 return Err(GitError::InvalidArgument(
130 "commit requires at least one parent".to_string(),
131 ));
132 }
133 let ctx = self.context(space_name, repo_name).await?;
134 let baby = self.baby(&ctx);
135 let parent_oids: Vec<gix::ObjectId> = match kind {
136 ObjectKind::Merge => {
137 let mut oids = Vec::with_capacity(parents.len());
138 for p in &parents {
139 oids.push(baby.rev_parse(p).await.map_err(map_baby)?);
140 }
141 oids
142 }
143 ObjectKind::Squash => {
144 vec![baby.rev_parse(&parents[0]).await.map_err(map_baby)?]
145 }
146 };
147 let tree_id = gix::ObjectId::from_hex(tree.trim().as_bytes())
148 .map_err(|_| GitError::InvalidArgument("invalid tree id".to_string()))?;
149 let repo = ctx.gix_repo().await.map_err(GitError::from)?;
150 let commit = Commit {
151 tree: tree_id,
152 parents: parent_oids.into(),
153 author: gix_signature(&author, 0),
154 committer: gix_signature(&committer, 0),
155 encoding: None,
156 message: BString::from(message.clone()),
157 extra_headers: Vec::new(),
158 };
159 let id = repo
160 .write_object(&commit)
161 .map_err(|e| GitError::Internal(format!("write commit failed: {e}")))?;
162 Ok(WriteObjectResultDto { id: id.to_string() })
163 }
164}
165
166fn clean_rel_path(path: &str) -> Result<String, GitError> {
167 let p = PathBuf::from(path);
168 if p.is_absolute()
169 || p.components()
170 .any(|c| matches!(c, std::path::Component::ParentDir))
171 || path.contains('\\')
172 || path.chars().any(|c| c.is_control())
173 {
174 return Err(GitError::InvalidArgument(format!(
175 "invalid relative path `{path}`"
176 )));
82 Err(GitError::Unimplemented(
83 "writing git commits requires a KenShell ContentWriteService",
84 ))
17785 }
178 Ok(p.to_string_lossy().replace('\\', "/"))
179}
180
181fn tree_entry_bytes(mode_octal: u32, name: &str, oid: ObjectId) -> Vec<u8> {
182 let mut buf = Vec::with_capacity(64 + name.len());
183 use std::io::Write as _;
184 let _ = write!(&mut buf, "{:o} ", mode_octal);
185 buf.extend_from_slice(name.as_bytes());
186 buf.push(0);
187 buf.extend_from_slice(oid.as_bytes());
188 buf
189}
190
191fn build_tree_recursive_sync(
192 prefix: &str,
193 blobs: &BTreeMap<String, ObjectId>,
194 write_tree: &mut dyn FnMut(&[u8]) -> Result<ObjectId, GitError>,
195) -> Result<ObjectId, GitError> {
196 // Group blobs by their first path segment relative to `prefix`.
197 let mut direct: BTreeMap<String, ObjectId> = BTreeMap::new();
198 let mut dir_blobs: BTreeMap<String, BTreeMap<String, ObjectId>> = BTreeMap::new();
199 for (path, oid) in blobs {
200 if !path.starts_with(prefix) {
201 continue;
202 }
203 let rest = &path[prefix.len()..];
204 if rest.is_empty() {
205 continue;
206 }
207 let head = rest.split('/').next().unwrap_or_default();
208 if rest == head {
209 direct.insert(head.to_string(), *oid);
210 } else {
211 let _dir = format!("{prefix}{head}/");
212 let entry = dir_blobs.entry(head.to_string()).or_default();
213 entry.insert(path.clone(), *oid);
214 }
215 }
216 let mut entries: Vec<(String, ObjectId)> = Vec::new();
217 for (name, oid) in &direct {
218 entries.push((name.clone(), *oid));
219 }
220 for (dir_name, sub_blobs) in &dir_blobs {
221 let sub_prefix = format!("{prefix}{dir_name}/");
222 let sub_oid = build_tree_recursive_sync(&sub_prefix, sub_blobs, write_tree)?;
223 entries.push((format!("{dir_name}/"), sub_oid));
224 }
225 entries.sort_by(|a, b| a.0.cmp(&b.0));
226 let mut buf: Vec<u8> = Vec::new();
227 for (name, oid) in entries {
228 let (mode, name) = if let Some(plain) = name.strip_suffix('/') {
229 (0o040000u32, plain)
230 } else {
231 (0o100644u32, name.as_str())
232 };
233 buf.extend_from_slice(&tree_entry_bytes(mode, name, oid));
234 }
235 let mut object = format!("tree {}\0", buf.len()).into_bytes();
236 object.extend_from_slice(&buf);
237 write_tree(&object)
238}
239
240fn build_tree_recursive(
241 repo: &gix::Repository,
242 prefix: &str,
243 blobs: &BTreeMap<String, ObjectId>,
244) -> Result<ObjectId, GitError> {
245 let objects = repo.objects.clone();
246 let mut write_tree = move |data: &[u8]| -> Result<ObjectId, GitError> {
247 objects
248 .write_buf(Kind::Tree, data)
249 .map_err(|e| GitError::Internal(format!("write tree failed: {e}")))
250 };
251 build_tree_recursive_sync(prefix, blobs, &mut write_tree)
25286}
Modified lib/git/src/service/context.rs View lib/git/src/service/context.rs
@@ -57,8 +57,9 @@
5757 model: repo,
5858 cache,
5959 database: self.state.database.clone(),
60 space_name: space.lower_name,
6061 repo_root: PathBuf::from(&git.repo_root),
61 objects_root: PathBuf::from(&git.objects_root),
62 ks: self.ks().clone(),
6263 })
6364 }
6465}
Modified lib/git/src/service/diffs.rs View lib/git/src/service/diffs.rs
@@ -1,8 +1,8 @@
1use gitbaby::diff::types::{
1use crate::rpcgit::diff::types::{
22 ChangedPath, ChangedPathDiff, ChangedPathRequest, DiffOptions, DiffRequest, NumStat,
33 RangeDiffSpec, ShortStat,
44};
5use gix::ObjectId;
5use crate::rpcgit::oid::ObjectId;
66use serde::{Deserialize, Serialize};
77
88use crate::service::GitService;
@@ -318,7 +318,7 @@
318318 &left,
319319 &right,
320320 std::path::Path::new(""),
321 gitbaby::tree::types::DiffTreesOptions {
321 crate::rpcgit::tree::types::DiffTreesOptions {
322322 recurse: true,
323323 detect_renames: true,
324324 ..Default::default()
Deleted lib/git/src/service/gitcmd.rs
@@ -1,118 +0,0 @@
1use std::path::Path;
2
3use gitbaby::command::cmd::{Cmd, CmdOutput};
4use gitbaby::command::env::Env;
5
6use crate::share::errors::GitError;
7
8fn build_cmd(
9 label: &str,
10 dir: &Path,
11 args: Vec<String>,
12 env: &[(String, String)],
13 timeout_secs: u64,
14) -> Cmd {
15 let mut cmd = Cmd::new(
16 label.to_string(),
17 "git",
18 args,
19 Vec::new(),
20 dir.to_path_buf(),
21 Env::new(),
22 Some(timeout_secs),
23 );
24 cmd.env.set("GIT_TERMINAL_PROMPT", "0");
25 for (k, v) in env {
26 cmd.env.set(k.clone(), v.clone());
27 }
28 cmd
29}
30
31pub async fn run_git(
32 label: impl Into<String>,
33 dir: &Path,
34 args: Vec<String>,
35 env: &[(String, String)],
36 timeout_secs: u64,
37) -> Result<CmdOutput, GitError> {
38 let label = label.into();
39 let mut cmd = build_cmd(&label, dir, args, env, timeout_secs);
40 cmd.run()
41 .await
42 .map_err(|e| GitError::Internal(format!("{label} failed: {e}")))
43}
44
45pub async fn run_git_soft(
46 label: impl Into<String>,
47 dir: &Path,
48 args: Vec<String>,
49 env: &[(String, String)],
50 timeout_secs: u64,
51) -> Result<CmdOutput, GitError> {
52 let label = label.into();
53 let mut cmd = build_cmd(&label, dir, args, env, timeout_secs);
54 cmd.run_soft()
55 .await
56 .map_err(|e| GitError::Internal(format!("{label} failed: {e}")))
57}
58
59pub async fn run_git_capped(
60 label: impl Into<String>,
61 dir: &Path,
62 args: Vec<String>,
63 env: &[(String, String)],
64 timeout_secs: u64,
65 output_cap: usize,
66) -> Result<CmdOutput, GitError> {
67 let label = label.into();
68 let mut cmd = build_cmd(&label, dir, args, env, timeout_secs);
69 cmd.output_cap = output_cap;
70 cmd.run()
71 .await
72 .map_err(|e| GitError::Internal(format!("{label} failed: {e}")))
73}
74
75pub async fn run_exec_cmd(
76 label: impl Into<String>,
77 prog: &str,
78 dir: &Path,
79 args: &[String],
80 timeout_secs: u64,
81 output_cap: usize,
82) -> Result<CmdOutput, GitError> {
83 let label = label.into();
84 let mut cmd = Cmd::new(
85 label.clone(),
86 prog.to_string(),
87 args.to_vec(),
88 Vec::new(),
89 dir.to_path_buf(),
90 Env::new(),
91 Some(timeout_secs),
92 );
93 cmd.output_cap = output_cap;
94 cmd.run()
95 .await
96 .map_err(|e| GitError::Internal(format!("{label} failed: {e}")))
97}
98
99pub async fn run_git_with_stdin(
100 label: impl Into<String>,
101 dir: &Path,
102 args: Vec<String>,
103 env: &[(String, String)],
104 stdin_data: &[u8],
105 timeout_secs: u64,
106) -> Result<CmdOutput, GitError> {
107 let label = label.into();
108 let mut cmd = build_cmd(&label, dir, args, env, timeout_secs);
109 cmd.spawn()
110 .await
111 .map_err(|e| GitError::Internal(format!("{label} spawn failed: {e}")))?;
112 cmd.feed(stdin_data)
113 .await
114 .map_err(|e| GitError::Internal(format!("{label} stdin failed: {e}")))?;
115 cmd.run()
116 .await
117 .map_err(|e| GitError::Internal(format!("{label} failed: {e}")))
118}
Modified lib/git/src/service/hooks.rs View lib/git/src/service/hooks.rs
@@ -1,7 +1,6 @@
11use serde::{Deserialize, Serialize};
22
33use crate::service::GitService;
4use crate::service::gitcmd::run_exec_cmd;
54use crate::share::errors::GitError;
65
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
@@ -65,43 +64,13 @@
6564
6665 pub async fn hook_execute_script(
6766 &self,
68 space_name: String,
69 repo_name: String,
70 script: HookEventKind,
71 args: Vec<String>,
67 _space_name: String,
68 _repo_name: String,
69 _script: HookEventKind,
70 _args: Vec<String>,
7271 ) -> Result<ScriptOutputDto, GitError> {
73 if !self.hook_supported(script) {
74 return Err(GitError::InvalidArgument(format!(
75 "unsupported hook script `{script:?}`"
76 )));
77 }
78 if args.iter().any(|a| a.len() > 1024) || args.len() > 16 {
79 return Err(GitError::InvalidArgument(
80 "invalid hook arguments".to_string(),
81 ));
82 }
83 let filename = match script {
84 HookEventKind::PreReceive => "pre-receive",
85 HookEventKind::PostReceive => "post-receive",
86 _ => unreachable!("rejected by hook_supported"),
87 };
88 let ctx = self.context(space_name, repo_name).await?;
89 let dir = ctx.repo_dir().join("hooks");
90 let prog = dir.join(filename);
91 let timeout = self.state.config.git.git_command_timeout_secs;
92 let out = run_exec_cmd(
93 "hooks::execute_script",
94 &prog.to_string_lossy(),
95 &dir,
96 &args,
97 timeout,
98 64 * 1024 * 1024,
99 )
100 .await?;
101 Ok(ScriptOutputDto {
102 exit_code: out.status.code().unwrap_or(-1),
103 stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
104 stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
105 })
72 Err(GitError::Unimplemented(
73 "hook script execution requires a KenShell HookService",
74 ))
10675 }
10776}
Modified lib/git/src/service/lfs.rs View lib/git/src/service/lfs.rs
@@ -1,8 +1,3 @@
1//! Thin delegation layer: keeps the stable `GitService` LFS API surface while
2//! all logic lives in [`crate::lfs`].
3
4use uuid::Uuid;
5
61use crate::lfs;
72pub use crate::lfs::types::*;
83use crate::service::GitService;
@@ -11,93 +6,85 @@
116impl GitService {
127 pub async fn lfs_batch(
138 &self,
14 space_name: String,
15 repo_name: String,
16 req: LfsBatchRequest,
9 _space_name: String,
10 _repo_name: String,
11 _req: LfsBatchRequest,
1712 ) -> Result<LfsBatchResponse, GitError> {
18 lfs::batch(self, space_name, repo_name, req).await
13 Err(GitError::Unimplemented(
14 "LFS object transfer is served by KenShell",
15 ))
1916 }
2017
2118 #[allow(clippy::too_many_arguments)]
2219 pub async fn lfs_upload(
2320 &self,
24 space_name: String,
25 repo_name: String,
26 caller: i64,
27 oid: String,
28 size: i64,
29 content_type: Option<String>,
30 body: Vec<u8>,
21 _space_name: String,
22 _repo_name: String,
23 _caller: i64,
24 _oid: String,
25 _size: i64,
26 _content_type: Option<String>,
27 _body: Vec<u8>,
3128 ) -> Result<bool, GitError> {
32 lfs::upload(
33 self,
34 space_name,
35 repo_name,
36 caller,
37 oid,
38 size,
39 content_type,
40 body,
41 )
42 .await
29 Err(GitError::Unimplemented(
30 "LFS object upload is served by KenShell",
31 ))
4332 }
4433
4534 #[allow(clippy::too_many_arguments)]
4635 pub async fn lfs_upload_stream<R>(
4736 &self,
48 space_name: String,
49 repo_name: String,
50 caller: i64,
51 oid: String,
52 size: i64,
53 content_type: Option<String>,
54 reader: R,
37 _space_name: String,
38 _repo_name: String,
39 _caller: i64,
40 _oid: String,
41 _size: i64,
42 _content_type: Option<String>,
43 _reader: R,
5544 ) -> Result<bool, GitError>
5645 where
5746 R: tokio::io::AsyncRead + Unpin + Send,
5847 {
59 lfs::upload_stream(
60 self,
61 space_name,
62 repo_name,
63 caller,
64 oid,
65 size,
66 content_type,
67 reader,
68 )
69 .await
48 Err(GitError::Unimplemented(
49 "LFS object upload is served by KenShell",
50 ))
7051 }
7152
7253 pub async fn lfs_download_to<W>(
7354 &self,
74 space_name: String,
75 repo_name: String,
76 oid: String,
77 writer: &mut W,
55 _space_name: String,
56 _repo_name: String,
57 _oid: String,
58 _writer: &mut W,
7859 ) -> Result<u64, GitError>
7960 where
8061 W: tokio::io::AsyncWrite + Send + Unpin,
8162 {
82 lfs::download_to(self, space_name, repo_name, oid, writer).await
63 Err(GitError::Unimplemented(
64 "LFS object download is served by KenShell",
65 ))
8366 }
8467
8568 pub async fn lfs_verify(
8669 &self,
87 space_name: String,
88 repo_name: String,
89 req: LfsVerifyRequest,
70 _space_name: String,
71 _repo_name: String,
72 _req: LfsVerifyRequest,
9073 ) -> Result<(), GitError> {
91 lfs::verify_object(self, space_name, repo_name, req).await
74 Err(GitError::Unimplemented(
75 "LFS object verification is served by KenShell",
76 ))
9277 }
9378
9479 pub async fn lfs_download(
9580 &self,
96 space_name: String,
97 repo_name: String,
98 oid: String,
81 _space_name: String,
82 _repo_name: String,
83 _oid: String,
9984 ) -> Result<Vec<u8>, GitError> {
100 lfs::download(self, space_name, repo_name, oid).await
85 Err(GitError::Unimplemented(
86 "LFS object download is served by KenShell",
87 ))
10188 }
10289
10390 pub async fn lfs_lock(
@@ -155,9 +142,9 @@
155142
156143 pub async fn lfs_cleanup_unreferenced(
157144 &self,
158 space_name: String,
159 repo_name: String,
145 _space_name: String,
146 _repo_name: String,
160147 ) -> Result<i64, GitError> {
161 lfs::cleanup_unreferenced(self, space_name, repo_name).await
148 Err(GitError::Unimplemented("LFS cleanup is served by KenShell"))
162149 }
163150}
Modified lib/git/src/service/logs.rs View lib/git/src/service/logs.rs
@@ -1,4 +1,4 @@
1use gitbaby::commit::types::{CommitInfo, ListCommitsOptions};
1use crate::rpcgit::commit::types::{CommitInfo, ListCommitsOptions};
22use serde::{Deserialize, Serialize};
33
44use crate::service::GitService;
Modified lib/git/src/service/maintenance.rs View lib/git/src/service/maintenance.rs
@@ -1,8 +1,7 @@
11use serde::Serialize;
22
33use crate::service::GitService;
4use crate::service::gitcmd::run_git_soft;
5use crate::share::errors::{GitError, map_baby};
4use crate::share::errors::GitError;
65
76#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
87pub struct PruneResultDto {
@@ -21,56 +20,21 @@
2120impl GitService {
2221 pub async fn maintenance_prune(
2322 &self,
24 space_name: String,
25 repo_name: String,
23 _space_name: String,
24 _repo_name: String,
2625 ) -> Result<PruneResultDto, GitError> {
27 let ctx = self.context(space_name, repo_name).await?;
28 self.baby(&ctx).prune().await.map_err(map_baby)?;
29 let timeout = self.state.config.git.git_command_timeout_secs;
30 let out = run_git_soft(
31 "maintenance::count_objects",
32 &ctx.repo_dir(),
33 vec!["count-objects".to_string(), "-v".to_string()],
34 &[],
35 timeout,
36 )
37 .await?;
38 let mut total: i64 = 0;
39 if out.status.success() {
40 for line in String::from_utf8_lossy(&out.stdout).lines() {
41 if let Some(rest) = line.strip_prefix("count: ") {
42 total = rest.trim().parse().unwrap_or(0);
43 }
44 }
45 }
46 Ok(PruneResultDto {
47 reachable: total,
48 unreachable: 0,
49 total,
50 })
26 Err(GitError::Unimplemented(
27 "repository prune is served by KenShell",
28 ))
5129 }
5230
5331 pub async fn maintenance_fsck(
5432 &self,
55 space_name: String,
56 repo_name: String,
33 _space_name: String,
34 _repo_name: String,
5735 ) -> Result<FsckDto, GitError> {
58 let ctx = self.context(space_name, repo_name).await?;
59 let timeout = self.state.config.git.git_command_timeout_secs;
60 let out = run_git_soft(
61 "maintenance::fsck",
62 &ctx.repo_dir(),
63 vec!["fsck".to_string(), "--no-progress".to_string()],
64 &[],
65 timeout,
66 )
67 .await?;
68 let stdout = String::from_utf8_lossy(&out.stdout);
69 let dangling = stdout.lines().filter(|l| l.contains("dangling")).count() as i64;
70 Ok(FsckDto {
71 ok: out.status.success(),
72 dangling,
73 broken: if out.status.success() { 0 } else { 1 },
74 })
36 Err(GitError::Unimplemented(
37 "repository fsck is served by KenShell",
38 ))
7539 }
7640}
Modified lib/git/src/service/merge.rs View lib/git/src/service/merge.rs
@@ -1,10 +1,4 @@
1use gitbaby::commit::types::CommitInfo;
2use gitbaby::repo::RepositoryFacade;
3use gix::ObjectId;
4use gix::actor::Signature;
5use gix::bstr::BString;
6use gix::date::Time;
7use gix::objs::Commit;
1use crate::rpcgit::commit::types::CommitInfo;
82use models::pull_request::pull_requests::MergeMethod;
93use serde::{Deserialize, Serialize};
104use time::OffsetDateTime;
@@ -12,13 +6,7 @@
126
137use crate::context::facade::GitContext;
148use crate::service::GitService;
15use crate::service::content::SignatureDto;
16use crate::share::access::space_for_dir;
179use crate::share::errors::{GitError, map_baby};
18use crate::sync::SyncQueue;
19use crate::sync::{PostReceiveRefUpdate, PostReceiveTask, enqueue_post_receive};
20
21const MAX_REBASE_COMMITS: u32 = 500;
2210
2311#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
2412pub struct MergePlanDto {
@@ -57,367 +45,33 @@
5745 pub summary: String,
5846}
5947
60fn hex(oid: &ObjectId) -> String {
48fn hex(oid: &crate::rpcgit::oid::ObjectId) -> String {
6149 oid.to_string()
6250}
6351
64fn baby_signature(sig: &gitbaby::commit::types::Signature) -> Signature {
65 Signature {
66 name: BString::from(sig.name.clone()),
67 email: BString::from(sig.email.clone()),
68 time: Time {
69 seconds: sig.time.unix_timestamp(),
70 offset: 0,
71 },
72 }
73}
74
75fn dto_signature(sig: &SignatureDto) -> Signature {
76 Signature {
77 name: BString::from(sig.name.clone()),
78 email: BString::from(sig.email.clone()),
79 time: Time {
80 seconds: sig.when.unix_timestamp(),
81 offset: 0,
82 },
83 }
84}
85
8652impl GitService {
53 /// 合并在 KenShell 补齐 MergeService 之前不可用。
8754 pub async fn merge_plan(
8855 &self,
89 ctx: &GitContext,
90 base: &str,
91 head: &str,
56 _ctx: &GitContext,
57 _base: &str,
58 _head: &str,
9259 ) -> Result<MergePlanDto, GitError> {
93 let baby = self.baby(ctx);
94 let base_sha = baby.rev_parse(base).await.map_err(map_baby)?;
95 let head_sha = baby.rev_parse(head).await.map_err(map_baby)?;
96 let base_str = hex(&base_sha);
97 let head_str = hex(&head_sha);
98
99 let fast_forwardable = baby
100 .is_ancestor(&base_str, &head_str)
101 .await
102 .map_err(map_baby)?;
103 let merge_base = baby.merge_base(&base_str, &head_str).await.ok();
104 let diverging = baby
105 .diverging_commits(&base_str, &head_str)
106 .await
107 .map_err(map_baby)?;
108 let conflict_files = baby
109 .list_conflict_files(
110 &base_str,
111 &head_str,
112 &gitbaby::conflict::types::ListConflictOptions {
113 allow_tree_conflicts: false,
114 skip_content: true,
115 },
116 )
117 .await
118 .map_err(map_baby)?;
119 let conflicts: Vec<String> = conflict_files
120 .iter()
121 .filter_map(|f| {
122 f.header
123 .our_path
124 .clone()
125 .map(|p| p.to_string_lossy().into_owned())
126 .or_else(|| {
127 f.header
128 .their_path
129 .clone()
130 .map(|p| p.to_string_lossy().into_owned())
131 })
132 })
133 .collect();
134
135 Ok(MergePlanDto {
136 base_sha: base_str,
137 head_sha: head_str,
138 merge_base: merge_base.map(|o| hex(&o)),
139 fast_forwardable,
140 base_only: i64::from(diverging.ahead),
141 head_only: i64::from(diverging.behind),
142 clean: conflicts.is_empty(),
143 conflicts,
144 })
60 Err(GitError::Unimplemented(
61 "merge planning requires KenShell MergeService",
62 ))
14563 }
14664
14765 pub async fn merge_execute(
14866 &self,
149 space_name: String,
150 repo_name: String,
151 cmd: ExecuteMergeCmd,
67 _space_name: String,
68 _repo_name: String,
69 _cmd: ExecuteMergeCmd,
15270 ) -> Result<MergeResultDto, GitError> {
153 let ctx = self.context(space_name, repo_name).await?;
154 let base_ref = format!("refs/heads/{}", cmd.base_branch.trim());
155 if cmd.base_branch.trim().is_empty() || cmd.base_branch.contains([' ', '\0']) {
156 return Err(GitError::InvalidArgument("invalid base branch".to_string()));
157 }
158 let plan = self.merge_plan(&ctx, &base_ref, &cmd.head_revision).await?;
159 if plan.head_only == 0 {
160 return Err(GitError::Conflict(
161 "no commits to merge; head is already contained in base".to_string(),
162 ));
163 }
164 if !plan.clean {
165 return Err(GitError::Conflict(format!(
166 "merge conflict in {} file(s): {}",
167 plan.conflicts.len(),
168 plan.conflicts.join(", ")
169 )));
170 }
171 let now = OffsetDateTime::now_utc();
172 let caller_sig = self.caller_signature(cmd.caller, now).await?;
173 let baby = self.baby(&ctx);
174 let base_oid = crate::service::diffs::parse_oid(&plan.base_sha)?;
175 let head_oid = crate::service::diffs::parse_oid(&plan.head_sha)?;
176
177 let (method, new_oid, rewritten) = match cmd.method {
178 MergeMethod::Merge if plan.fast_forwardable && cmd.allow_ff_only => {
179 ("fast_forward".to_string(), head_oid, Vec::new())
180 }
181 MergeMethod::Merge => {
182 let tree = self.merged_tree(&ctx, &plan).await?;
183 let sha = self
184 .write_commit(
185 &ctx,
186 tree,
187 vec![base_oid, head_oid],
188 dto_signature(&caller_sig),
189 dto_signature(&caller_sig),
190 &cmd.message.clone().unwrap_or_else(|| {
191 format!(
192 "Merge branch '{}' into '{}' {}",
193 branch_label(&cmd.head_revision),
194 cmd.base_branch,
195 cmd.summary
196 )
197 .trim()
198 .to_string()
199 }),
200 )
201 .await?;
202 ("merge_commit".to_string(), sha, Vec::new())
203 }
204 MergeMethod::Squash => {
205 let tree = self.merged_tree(&ctx, &plan).await?;
206 let sha = self
207 .write_commit(
208 &ctx,
209 tree,
210 vec![base_oid],
211 dto_signature(&caller_sig),
212 dto_signature(&caller_sig),
213 &cmd.message.clone().unwrap_or_else(|| {
214 format!("{} ({})", cmd.summary, "squash").trim().to_string()
215 }),
216 )
217 .await?;
218 ("squash".to_string(), sha, Vec::new())
219 }
220 MergeMethod::Rebase => {
221 let (sha, list) = self
222 .rebase_chain(&ctx, &plan, &caller_sig, &cmd.summary)
223 .await?;
224 ("rebase".to_string(), sha, list)
225 }
226 };
227
228 baby.update_ref(&base_ref, new_oid, Some(base_oid))
229 .await
230 .map_err(map_baby)?;
231
232 self.enqueue_ref_sync(
233 &ctx,
234 Some(cmd.caller),
235 &base_ref,
236 &plan.base_sha,
237 &hex(&new_oid),
238 )
239 .await;
240
241 Ok(MergeResultDto {
242 method,
243 base_ref,
244 old_sha: plan.base_sha,
245 new_sha: hex(&new_oid),
246 head_sha: plan.head_sha,
247 rewritten,
248 })
249 }
250
251 async fn merged_tree(
252 &self,
253 ctx: &GitContext,
254 plan: &MergePlanDto,
255 ) -> Result<ObjectId, GitError> {
256 let baby = self.baby(ctx);
257 let result = baby
258 .merge_tree(
259 &plan.base_sha,
260 &plan.head_sha,
261 plan.merge_base.as_deref(),
262 false,
263 )
264 .await
265 .map_err(map_baby)?;
266 if result.has_conflicts {
267 return Err(GitError::Conflict(format!(
268 "merge conflict in {} file(s)",
269 result.conflict_files.len()
270 )));
271 }
272 Ok(result.tree_oid)
273 }
274
275 async fn rebase_chain(
276 &self,
277 ctx: &GitContext,
278 plan: &MergePlanDto,
279 caller_sig: &SignatureDto,
280 summary: &str,
281 ) -> Result<(ObjectId, Vec<String>), GitError> {
282 let baby = self.baby(ctx);
283 let ids = baby
284 .commit_ids_between_reverse(
285 &plan.base_sha,
286 &plan.head_sha,
287 None,
288 Some(MAX_REBASE_COMMITS),
289 )
290 .await
291 .map_err(map_baby)?;
292 if ids.is_empty() {
293 return Err(GitError::Conflict("no commits to rebase".to_string()));
294 }
295 let total = baby
296 .commit_ids_between_reverse(&plan.base_sha, &plan.head_sha, None, None)
297 .await
298 .map_err(map_baby)?;
299 if total.len() > MAX_REBASE_COMMITS as usize {
300 return Err(GitError::InvalidArgument(format!(
301 "rebase supports at most {MAX_REBASE_COMMITS} commits"
302 )));
303 }
304
305 let committer = dto_signature(caller_sig);
306 let mut parent = crate::service::diffs::parse_oid(&plan.base_sha)?;
307 let mut out = Vec::with_capacity(ids.len());
308 for pick in &ids {
309 let info = self.commit_info(ctx, &hex(pick)).await?;
310 let previous = info
311 .parents
312 .first()
313 .cloned()
314 .map(|o| hex(&o))
315 .unwrap_or_else(|| plan.merge_base.clone().unwrap_or_default());
316 // 无父提交(仓库首个提交)按 cherry-pick 语义整树引入。
317 let tree = if info.parents.is_empty() {
318 info.tree_id
319 } else {
320 baby.merge_tree(&hex(&parent), &hex(pick), Some(&previous), false)
321 .await
322 .map_err(|e| match e {
323 gitbaby::error::BabyError::Custom(_) => GitError::Conflict(format!(
324 "rebase stopped while picking {}: {}",
325 hex(pick),
326 e
327 )),
328 other => map_baby(other),
329 })?
330 .tree_oid
331 };
332 let message = if out.is_empty() && !summary.is_empty() && ids.len() == 1 {
333 format!("{}\n\n{}", info.message.trim_end(), summary)
334 } else {
335 info.message.clone()
336 };
337 let sha = self
338 .write_commit(
339 ctx,
340 tree,
341 vec![parent],
342 baby_signature(&info.author),
343 committer.clone(),
344 &message,
345 )
346 .await?;
347 parent = sha;
348 out.push(hex(&sha));
349 }
350 Ok((parent, out))
351 }
352
353 async fn commit_info(&self, ctx: &GitContext, sha: &str) -> Result<CommitInfo, GitError> {
354 self.baby(ctx).get_commit(sha).await.map_err(map_baby)
355 }
356
357 #[allow(clippy::too_many_arguments)]
358 async fn write_commit(
359 &self,
360 ctx: &GitContext,
361 tree: ObjectId,
362 parents: Vec<ObjectId>,
363 author: Signature,
364 committer: Signature,
365 message: &str,
366 ) -> Result<ObjectId, GitError> {
367 let repo = ctx
368 .gix_repo()
369 .await
370 .map_err(|e| GitError::Internal(format!("open repo failed: {e}")))?;
371 let commit = Commit {
372 tree,
373 parents: parents.into(),
374 author,
375 committer,
376 encoding: None,
377 message: BString::from(message.to_string()),
378 extra_headers: Vec::new(),
379 };
380 let header = repo
381 .write_object(&commit)
382 .map_err(|e| GitError::Internal(format!("write commit failed: {e}")))?;
383 crate::service::diffs::parse_oid(&header.to_string())
384 }
385
386 async fn enqueue_ref_sync(
387 &self,
388 ctx: &GitContext,
389 actor: Option<i64>,
390 ref_name: &str,
391 old_sha: &str,
392 new_sha: &str,
393 ) {
394 let space = match space_for_dir(self.db_read(), ctx.model.dir_id).await {
395 Ok(Some(space)) => space,
396 Ok(None) => {
397 tracing::warn!(repo_id = %ctx.model.id, "merge sync skipped: owning space not found");
398 return;
399 }
400 Err(e) => {
401 tracing::warn!(error = %e, "merge sync skipped: space lookup failed");
402 return;
403 }
404 };
405 let queue = SyncQueue::new(self.state.redis.clone());
406 let settings = self.state.config.git.sync.clone();
407 let task = PostReceiveTask::new(
408 ctx.model.id,
409 space.id,
410 actor,
411 vec![PostReceiveRefUpdate::new(ref_name, old_sha, new_sha)],
412 );
413 if let Err(e) = enqueue_post_receive(&queue, &settings, &task).await {
414 tracing::warn!(error = %e, repo_id = %ctx.model.id, "merge ref sync enqueue failed");
415 }
71 Err(GitError::Unimplemented(
72 "merge execution requires KenShell MergeService",
73 ))
41674 }
417}
418
419fn branch_label(revision: &str) -> String {
420 revision.rsplit('/').next().unwrap_or(revision).to_string()
42175}
42276
42377#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -433,6 +87,18 @@
43387
43488const MAX_LISTED_COMMITS: u32 = 200;
43589
90fn commit_dto(info: CommitInfo) -> MergeCommitDto {
91 MergeCommitDto {
92 sha: hex(&info.id),
93 subject: info.message.lines().next().unwrap_or_default().to_string(),
94 message: info.message.clone(),
95 author_name: info.author.name.clone(),
96 author_email: info.author.email.clone(),
97 author_at: info.author.time,
98 parents: info.parents.iter().map(hex).collect(),
99 }
100}
101
436102impl GitService {
437103 /// `base..head` 的提交列表(正序,最多 200 条)。
438104 pub async fn merge_commits(
@@ -442,36 +108,10 @@
442108 base: &str,
443109 head: &str,
444110 ) -> Result<Vec<MergeCommitDto>, GitError> {
445 let ctx = self.context(space_name, repo_name).await?;
446 let baby = self.baby(&ctx);
447 let base_oid = baby.rev_parse(base).await.map_err(map_baby)?;
448 let head_oid = baby.rev_parse(head).await.map_err(map_baby)?;
449 let ids = baby
450 .commit_ids_between_reverse(
451 &hex(&base_oid),
452 &hex(&head_oid),
453 None,
454 Some(MAX_LISTED_COMMITS),
455 )
111 self.commits_between(space_name, repo_name, base, head, MAX_LISTED_COMMITS)
456112 .await
457 .map_err(map_baby)?;
458 let mut out = Vec::with_capacity(ids.len());
459 for id in ids {
460 let info = self.commit_info(&ctx, &hex(&id)).await?;
461 out.push(MergeCommitDto {
462 sha: hex(&id),
463 subject: info.message.lines().next().unwrap_or_default().to_string(),
464 message: info.message.clone(),
465 author_name: info.author.name.clone(),
466 author_email: info.author.email.clone(),
467 author_at: info.author.time,
468 parents: info.parents.iter().map(hex).collect(),
469 });
470 }
471 Ok(out)
472113 }
473}
474impl GitService {
114
475115 pub async fn recent_commits(
476116 &self,
477117 space_name: String,
@@ -481,35 +121,21 @@
481121 skip: u32,
482122 ) -> Result<Vec<MergeCommitDto>, GitError> {
483123 let ctx = self.context(space_name, repo_name).await?;
484 let baby = self.baby(&ctx);
485124 let reference = match revision.map(str::trim).filter(|value| !value.is_empty()) {
486125 Some(value) => value.to_string(),
487126 None => format!("refs/heads/{}", ctx.model.default_branch),
488127 };
489 let rev_oid = baby.rev_parse(&reference).await.map_err(map_baby)?;
490 let max = limit.min(MAX_LISTED_COMMITS).saturating_add(skip);
491 let commits = baby
492 .list_commits(gitbaby::commit::types::ListCommitsOptions {
493 revision: rev_oid.to_string(),
494 max_count: Some(max),
128 let commits = self
129 .baby(&ctx)
130 .list_commits(crate::rpcgit::commit::types::ListCommitsOptions {
131 revision: reference,
132 max_count: Some(limit.min(MAX_LISTED_COMMITS).saturating_add(skip)),
495133 skip: Some(skip),
496134 ..Default::default()
497135 })
498136 .await
499137 .map_err(map_baby)?;
500 let mut out = Vec::with_capacity(commits.len());
501 for info in commits {
502 out.push(MergeCommitDto {
503 sha: hex(&info.id),
504 subject: info.message.lines().next().unwrap_or_default().to_string(),
505 message: info.message.clone(),
506 author_name: info.author.name.clone(),
507 author_email: info.author.email.clone(),
508 author_at: info.author.time,
509 parents: info.parents.iter().map(hex).collect(),
510 });
511 }
512 Ok(out)
138 Ok(commits.into_iter().map(commit_dto).collect())
513139 }
514140
515141 pub async fn commits_between(
@@ -524,23 +150,20 @@
524150 let baby = self.baby(&ctx);
525151 let base_oid = baby.rev_parse(base).await.map_err(map_baby)?;
526152 let head_oid = baby.rev_parse(head).await.map_err(map_baby)?;
527 let max = limit.min(MAX_LISTED_COMMITS);
528153 let ids = baby
529 .commit_ids_between_reverse(&hex(&base_oid), &hex(&head_oid), None, Some(max))
154 .commit_ids_between_reverse(
155 &hex(&base_oid),
156 &hex(&head_oid),
157 None,
158 Some(limit.min(MAX_LISTED_COMMITS)),
159 )
530160 .await
531161 .map_err(map_baby)?;
532162 let mut out = Vec::with_capacity(ids.len());
533163 for id in ids {
534 let info = self.commit_info(&ctx, &hex(&id)).await?;
535 out.push(MergeCommitDto {
536 sha: hex(&id),
537 subject: info.message.lines().next().unwrap_or_default().to_string(),
538 message: info.message.clone(),
539 author_name: info.author.name.clone(),
540 author_email: info.author.email.clone(),
541 author_at: info.author.time,
542 parents: info.parents.iter().map(hex).collect(),
543 });
164 out.push(commit_dto(
165 baby.get_commit(&hex(&id)).await.map_err(map_baby)?,
166 ));
544167 }
545168 Ok(out)
546169 }
@@ -577,7 +200,7 @@
577200 Err(_) => return Ok(None),
578201 };
579202 match info.parents.first() {
580 Some(parent) => cursor = *parent,
203 Some(parent) => cursor = parent.clone(),
581204 None => return Ok(None),
582205 }
583206 }
Modified lib/git/src/service/mod.rs View lib/git/src/service/mod.rs
@@ -1,23 +1,42 @@
11use std::sync::Arc;
2use std::time::Duration;
23
34use export::AppState;
4use gitbaby::GitBaby;
5use kenshell::KenShellClient;
56use sea_orm::DatabaseConnection;
67
78use crate::context::facade::GitContext;
9use crate::rpcgit::GitBaby;
810
911#[derive(Clone)]
1012pub struct GitService {
1113 pub state: AppState,
14 ks: Arc<KenShellClient>,
1215}
1316
1417impl GitService {
1518 pub fn new(state: AppState) -> Self {
16 // Every host builds a GitService before it can touch a repository, so
17 // this is the one place that can still shape the environment the `git`
18 // children will inherit. See `share::git_env` for what it pins and why.
19 crate::share::git_env::init();
20 Self { state }
19 let cfg = &state.config.git;
20 let timeout = Duration::from_secs(cfg.kenshell_timeout_secs.max(1));
21 let ks = KenShellClient::connect_with_key(
22 &cfg.kenshell_endpoint,
23 &cfg.kenshell_rpc_key,
24 timeout,
25 )
26 .unwrap_or_else(|error| {
27 panic!(
28 "invalid git.kenshell_endpoint `{}`: {error}",
29 cfg.kenshell_endpoint
30 )
31 });
32 Self {
33 state,
34 ks: Arc::new(ks),
35 }
36 }
37
38 pub fn ks(&self) -> &KenShellClient {
39 &self.ks
2140 }
2241
2342 pub fn baby(&self, ctx: &GitContext) -> GitBaby {
@@ -46,7 +65,6 @@
4665pub mod content;
4766pub mod context;
4867pub mod diffs;
49pub mod gitcmd;
5068pub mod hooks;
5169pub mod lfs;
5270pub mod licenses;
@@ -62,6 +80,7 @@
6280pub mod releases;
6381pub mod remotes;
6482pub mod repos;
83pub mod rpc;
6584pub mod signatures;
6685pub mod stars;
6786pub mod submodules;
Modified lib/git/src/service/packs.rs View lib/git/src/service/packs.rs
@@ -1,8 +1,7 @@
11use serde::Serialize;
22
33use crate::service::GitService;
4use crate::service::gitcmd::{run_git, run_git_soft, run_git_with_stdin};
5use crate::share::errors::{GitError, map_baby};
4use crate::share::errors::GitError;
65
76#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
87pub struct IngestPackDto {
@@ -19,80 +18,26 @@
1918impl GitService {
2019 pub async fn pack_ingest(
2120 &self,
22 space_name: String,
23 repo_name: String,
24 pack: Vec<u8>,
21 _space_name: String,
22 _repo_name: String,
23 _pack: Vec<u8>,
2524 ) -> Result<IngestPackDto, GitError> {
26 let ctx = self.context(space_name, repo_name).await?;
27 let timeout = self.state.config.git.git_command_timeout_secs;
28 let out = run_git_with_stdin(
29 "packs::ingest",
30 &ctx.repo_dir(),
31 vec!["index-pack".to_string(), "--stdin".to_string()],
32 &[],
33 &pack,
34 timeout,
35 )
36 .await?;
37 let mut object_count: i64 = 0;
38 for line in String::from_utf8_lossy(&out.stdout).lines() {
39 if let Some(rest) = line.strip_prefix("non delta: ")
40 && let Some(n) = rest.split_whitespace().next()
41 {
42 object_count = n.parse().unwrap_or(0);
43 }
44 }
45 let size = pack.len() as i64;
46 Ok(IngestPackDto { object_count, size })
25 Err(GitError::Unimplemented("pack ingest is served by KenShell"))
4726 }
4827
49 pub async fn pack_repack(&self, space_name: String, repo_name: String) -> Result<(), GitError> {
50 let ctx = self.context(space_name, repo_name).await?;
51 let timeout = self.state.config.git.git_command_timeout_secs;
52 run_git(
53 "packs::repack",
54 &ctx.repo_dir(),
55 vec![
56 "repack".to_string(),
57 "-a".to_string(),
58 "-d".to_string(),
59 "-q".to_string(),
60 ],
61 &[],
62 timeout,
63 )
64 .await?;
65 Ok(())
28 pub async fn pack_repack(
29 &self,
30 _space_name: String,
31 _repo_name: String,
32 ) -> Result<(), GitError> {
33 Err(GitError::Unimplemented("pack repack is served by KenShell"))
6634 }
6735
6836 pub async fn pack_stats(
6937 &self,
70 space_name: String,
71 repo_name: String,
38 _space_name: String,
39 _repo_name: String,
7240 ) -> Result<PackStatsDto, GitError> {
73 let ctx = self.context(space_name, repo_name).await?;
74 let timeout = self.state.config.git.git_command_timeout_secs;
75 let out = run_git_soft(
76 "packs::stats",
77 &ctx.repo_dir(),
78 vec!["count-objects".to_string(), "-v".to_string()],
79 &[],
80 timeout,
81 )
82 .await?;
83 let mut loose_count: i64 = 0;
84 let mut size: i64 = 0;
85 for line in String::from_utf8_lossy(&out.stdout).lines() {
86 if let Some(rest) = line.strip_prefix("count: ") {
87 loose_count = rest.trim().parse().unwrap_or(0);
88 } else if let Some(rest) = line.strip_prefix("size: ") {
89 size = rest.trim().parse().unwrap_or(0);
90 }
91 }
92 let _ = map_baby; // keep helper reachable
93 Ok(PackStatsDto {
94 loose_count,
95 size_bytes: size,
96 })
41 Err(GitError::Unimplemented("pack stats are served by KenShell"))
9742 }
9843}
Modified lib/git/src/service/patches.rs View lib/git/src/service/patches.rs
@@ -1,9 +1,7 @@
11use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
32
43use crate::service::GitService;
54use crate::service::cache::{self, IMMUTABLE_TTL};
6use crate::service::gitcmd::run_git_soft;
75use crate::share::errors::{GitError, map_baby};
86
97#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -40,7 +38,10 @@
4038 let key = format!("repo.{}.patch_generate.{}.{}", ctx.model.id, left, right);
4139 cache::cached_fetch(&ctx.cache, &key, IMMUTABLE_TTL, || async {
4240 let body = baby
43 .raw_diff(&gitbaby::diff::types::DiffRequest { left, right }, None)
41 .raw_diff(
42 &crate::rpcgit::diff::types::DiffRequest { left, right },
43 None,
44 )
4445 .await
4546 .map_err(map_baby)?;
4647 let size = body.len() as i64;
@@ -82,53 +83,13 @@
8283
8384 pub async fn patch_apply(
8485 &self,
85 space_name: String,
86 repo_name: String,
87 patch: String,
88 base: String,
86 _space_name: String,
87 _repo_name: String,
88 _patch: String,
89 _base: String,
8990 ) -> Result<AppliedPatchDto, GitError> {
90 let ctx = self.context(space_name, repo_name).await?;
91 let baby = self.baby(&ctx);
92 // Resolve base to a concrete oid so we can hand it to `git apply --check`.
93 let base_oid = baby.rev_parse(&base).await.map_err(map_baby)?;
94 let dir = ctx.repo_dir();
95 let tmp = std::env::temp_dir().join(format!(
96 "gitdata-patch-{}-{}",
97 std::process::id(),
98 OffsetDateTime::now_utc().unix_timestamp_nanos()
99 ));
100 tokio::fs::create_dir_all(&tmp)
101 .await
102 .map_err(GitError::from)?;
103 let patch_path = tmp.join("patch.diff");
104 tokio::fs::write(&patch_path, patch.as_bytes())
105 .await
106 .map_err(GitError::from)?;
107 let timeout = self.state.config.git.git_command_timeout_secs;
108 let out = run_git_soft(
109 "patches::apply",
110 &dir,
111 vec![
112 "apply".to_string(),
113 "--check".to_string(),
114 "--3way".to_string(),
115 patch_path.to_string_lossy().into_owned(),
116 ],
117 &[],
118 timeout,
119 )
120 .await?;
121 let _ = base_oid;
122 let applied = out.status.success();
123 let conflicts = if applied {
124 Vec::new()
125 } else {
126 String::from_utf8_lossy(&out.stderr)
127 .lines()
128 .filter_map(|l| l.strip_prefix("error: ").map(|s| s.to_string()))
129 .collect()
130 };
131 let _ = tokio::fs::remove_dir_all(&tmp).await;
132 Ok(AppliedPatchDto { applied, conflicts })
91 Err(GitError::Unimplemented(
92 "applying patches requires a KenShell PatchService",
93 ))
13394 }
13495}
Modified lib/git/src/service/path_log.rs View lib/git/src/service/path_log.rs
@@ -1,6 +1,6 @@
11use std::path::{Path, PathBuf};
22
3use gitbaby::commit::types::ListCommitsOptions;
3use crate::rpcgit::commit::types::ListCommitsOptions;
44use serde::{Deserialize, Serialize};
55
66use crate::service::GitService;
Modified lib/git/src/service/pdf.rs View lib/git/src/service/pdf.rs
@@ -7,8 +7,8 @@
77use serde_json::json;
88use tokio::sync::mpsc;
99
10use gitbaby::commit::types::TreeEntryMode;
11use gitbaby::error::BabyError;
10use crate::rpcgit::commit::types::TreeEntryMode;
11use crate::rpcgit::error::BabyError;
1212
1313use crate::lfs;
1414use crate::service::GitService;
@@ -106,7 +106,7 @@
106106 cache.pages.insert(key, CachedPage { png, width, height });
107107}
108108
109/// gitbaby resolves refs through `git`, so an unknown revision surfaces as a
109/// KenShell resolves refs through its git backend, so an unknown revision surfaces as a
110110/// non-zero exit rather than a typed error. On this read path the only thing
111111/// that matters is the resulting status: an unresolvable ref is a 404, exactly
112112/// like a missing path.
@@ -117,7 +117,7 @@
117117 | BabyError::RefNotFound(_)
118118 | BabyError::PathNotFound { .. } => true,
119119 BabyError::Command(cmd) => has_missing_ref_text(&cmd.to_string()),
120 // gitbaby only classifiers the `git` message shapes it knows; anything
120 // KenShell only classifies the git message shapes it knows; anything
121121 // else (such as `fatal: Not a valid object name`) lands here, so the
122122 // stderr has to be inspected once more.
123123 BabyError::Spawn { stderr, .. } => has_missing_ref_text(stderr),
@@ -178,15 +178,18 @@
178178 }
179179
180180 let max = service.state.config.git.max_blob_size;
181 let size = baby.get_blob_size(entry.oid).await.map_err(map_baby)?;
181 let size = baby
182 .get_blob_size(entry.oid.clone())
183 .await
184 .map_err(map_baby)?;
182185
183186 // Sniff before reading the whole file: `parse_lfs_pointer` only needs its
184187 // first lines, and a plain multi-hundred-megabyte PDF should be rejected by
185188 // the size cap rather than paged into memory first.
186189 let sniff = baby
187190 .get_blob(
188 entry.oid,
189 gitbaby::blob::types::GetBlobOptions {
191 entry.oid.clone(),
192 crate::rpcgit::blob::types::GetBlobOptions {
190193 limit: Some(blobs::SNIFF_LIMIT),
191194 },
192195 )
@@ -203,8 +206,8 @@
203206 }
204207 let blob = baby
205208 .get_blob(
206 entry.oid,
207 gitbaby::blob::types::GetBlobOptions { limit: None },
209 entry.oid.clone(),
210 crate::rpcgit::blob::types::GetBlobOptions { limit: None },
208211 )
209212 .await
210213 .map_err(map_baby)?;
Modified lib/git/src/service/refs.rs View lib/git/src/service/refs.rs
@@ -1,4 +1,4 @@
1use gitbaby::refs::types::{ListRefsOptions, RefInfo, RefType};
1use crate::rpcgit::refs::types::{ListRefsOptions, RefInfo, RefType};
22use serde::Serialize;
33
44use crate::service::GitService;
@@ -122,7 +122,7 @@
122122 let ctx = self.context(space_name, repo_name).await?;
123123 let baby = self.baby(&ctx);
124124 let branches = baby
125 .list_branches(gitbaby::branch::ListBranchesOptions::default())
125 .list_branches(crate::rpcgit::branch::ListBranchesOptions::default())
126126 .await
127127 .map_err(map_baby)?;
128128 let policy = load_branch_policy(&self.state.database, ctx.model.id).await?;
@@ -160,11 +160,13 @@
160160 }
161161 let baby = self.baby(&ctx);
162162 let new = crate::service::diffs::parse_oid(&new_oid)?;
163 let old: Option<gix::ObjectId> = match expected_oid {
163 let old: Option<crate::rpcgit::oid::ObjectId> = match expected_oid {
164164 Some(hex) => Some(crate::service::diffs::parse_oid(&hex)?),
165165 None => None,
166166 };
167 baby.update_ref(&name, new, old).await.map_err(map_baby)?;
167 baby.update_ref(&name, new.clone(), old.clone())
168 .await
169 .map_err(map_baby)?;
168170 Ok(UpdateRefResultDto {
169171 name,
170172 old_oid: old.map(|o| o.to_string()),
Modified lib/git/src/service/releases.rs View lib/git/src/service/releases.rs
@@ -1,4 +1,3 @@
1use crate::context::facade::GitContext;
21use export::pagination::Page;
32use models::repo::repo_release;
43use sea_orm::{
Modified lib/git/src/service/remotes.rs View lib/git/src/service/remotes.rs
@@ -1,9 +1,9 @@
1use kenshell::proto::v1 as pb;
12use serde::{Deserialize, Serialize};
23
34use crate::service::GitService;
4use crate::service::clone::{GitCredential, mirror_fetch};
5use crate::service::gitcmd::{run_git, run_git_soft};
6use crate::share::errors::{GitError, map_baby};
5use crate::service::rpc::{rpc_error, wait_job};
6use crate::share::errors::GitError;
77use crate::share::validate::{validate_refspec, validate_remote_name, validate_remote_url};
88
99#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
@@ -43,6 +43,28 @@
4343 pub refspec: Option<String>,
4444}
4545
46#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
47pub struct RemoteDto {
48 pub name: String,
49 pub fetch_url: Option<String>,
50 pub push_url: Option<String>,
51}
52
53fn remote_auth(auth: Option<&RemoteAuthDto>) -> Option<pb::RemoteAuth> {
54 let auth = auth?;
55 if auth.token.as_deref().unwrap_or("").is_empty() {
56 return None;
57 }
58 Some(pb::RemoteAuth {
59 username: auth.username.clone().unwrap_or_else(|| "git".to_string()),
60 token: auth.token.clone().unwrap_or_default(),
61 authorization_header: String::new(),
62 ssh_key: String::new(),
63 known_hosts: String::new(),
64 resolved_address: String::new(),
65 })
66}
67
4668impl GitService {
4769 pub async fn remote_clone_import(
4870 &self,
@@ -53,29 +75,28 @@
5375 ) -> Result<ImportResultDto, GitError> {
5476 validate_remote_url(&url)?;
5577 let ctx = self.context(space_name, repo_name).await?;
56 let dir = ctx.repo_dir();
57 let timeout = self.state.config.git.git_command_timeout_secs;
58 let credential = auth
59 .as_ref()
60 .and_then(|a| a.token.clone())
61 .filter(|t| !t.is_empty())
62 .map(|token| GitCredential {
63 username: auth
64 .as_ref()
65 .and_then(|a| a.username.clone())
66 .map(|u| u.trim().to_string())
67 .filter(|u| !u.is_empty())
68 .unwrap_or_else(|| "git".to_string()),
69 secret: token,
70 });
71 mirror_fetch("remotes::import", &dir, &url, credential.as_ref(), timeout).await?;
72 let baby = self.baby(&ctx);
73 let default = baby.get_default_branch().await.map_err(map_baby)?;
74 let commit_count = count_refs(&dir).await?;
75 Ok(ImportResultDto {
76 default_branch: default,
77 commit_count,
78 })
78 let mut client = ctx.ks.remote();
79 let job = client
80 .start_remote_import(pb::StartRemoteImportRequest {
81 repository: Some(ctx.repository_ref()),
82 url,
83 auth: remote_auth(auth.as_ref()),
84 actor: Some(ctx.actor()),
85 idempotency_key: uuid::Uuid::now_v7().to_string(),
86 })
87 .await
88 .map_err(rpc_error)?
89 .into_inner()
90 .job
91 .ok_or_else(|| GitError::Internal("import job missing".into()))?;
92 let job = wait_job(&ctx.ks, &job.id, self.job_timeout()).await?;
93 match job.result {
94 Some(pb::job::Result::Remote(remote)) => Ok(ImportResultDto {
95 default_branch: remote.default_branch,
96 commit_count: remote.commit_count as i64,
97 }),
98 _ => Err(GitError::Internal("import job returned no result".into())),
99 }
79100 }
80101
81102 pub async fn remote_fetch(
@@ -85,37 +106,30 @@
85106 remote: String,
86107 auth: Option<RemoteAuthDto>,
87108 ) -> Result<FetchResultDto, GitError> {
109 validate_remote_name(&remote)?;
88110 let ctx = self.context(space_name, repo_name).await?;
89 validate_remote_name(&remote)?;
90 let mut env: Vec<(String, String)> = Vec::new();
91 if let Some(a) = &auth
92 && let Some(t) = &a.token
93 {
94 env.push(("GIT_ASKPASS".to_string(), "/bin/true".to_string()));
95 env.push(("GIT_TOKEN".to_string(), t.clone()));
96 }
97 let timeout = self.state.config.git.git_command_timeout_secs;
98 let out = run_git(
99 "remotes::fetch",
100 &ctx.repo_dir(),
101 vec![
102 "fetch".to_string(),
103 "--prune".to_string(),
104 "--".to_string(),
111 let mut client = ctx.ks.remote();
112 let job = client
113 .start_remote_fetch(pb::StartRemoteFetchRequest {
114 repository: Some(ctx.repository_ref()),
105115 remote,
106 ],
107 &env,
108 timeout,
109 )
110 .await?;
111 let updated: Vec<String> = String::from_utf8_lossy(&out.stdout)
112 .lines()
113 .filter_map(|l| l.split_whitespace().nth(1).map(|s| s.to_string()))
114 .collect();
115 Ok(FetchResultDto {
116 updated,
117 new: Vec::new(),
118 })
116 auth: remote_auth(auth.as_ref()),
117 actor: Some(ctx.actor()),
118 idempotency_key: uuid::Uuid::now_v7().to_string(),
119 })
120 .await
121 .map_err(rpc_error)?
122 .into_inner()
123 .job
124 .ok_or_else(|| GitError::Internal("fetch job missing".into()))?;
125 let job = wait_job(&ctx.ks, &job.id, self.job_timeout()).await?;
126 match job.result {
127 Some(pb::job::Result::Remote(remote)) => Ok(FetchResultDto {
128 updated: remote.updated,
129 new: remote.new_refs,
130 }),
131 _ => Err(GitError::Internal("fetch job returned no result".into())),
132 }
119133 }
120134
121135 pub async fn remote_push(
@@ -126,44 +140,35 @@
126140 refspec: Option<String>,
127141 auth: Option<RemoteAuthDto>,
128142 ) -> Result<PushResultDto, GitError> {
129 let ctx = self.context(space_name, repo_name).await?;
130143 validate_remote_name(&remote)?;
131 let mut env: Vec<(String, String)> = Vec::new();
132 if let Some(a) = &auth
133 && let Some(t) = &a.token
134 {
135 env.push(("GIT_ASKPASS".to_string(), "/bin/true".to_string()));
136 env.push(("GIT_TOKEN".to_string(), t.clone()));
144 if let Some(refspec) = refspec.as_deref() {
145 validate_refspec(refspec)?;
137146 }
138 let mut args = vec!["push".to_string(), "--".to_string(), remote];
139 if let Some(r) = refspec {
140 validate_refspec(&r)?;
141 args.push(r);
142 }
143 let timeout = self.state.config.git.git_command_timeout_secs;
144 let out = run_git_soft("remotes::push", &ctx.repo_dir(), args, &env, timeout).await?;
145 let stdout = String::from_utf8_lossy(&out.stdout);
146 let pushed: Vec<String> = stdout
147 .lines()
148 .filter_map(|l| {
149 l.strip_prefix(" ")
150 .map(|s| s.to_string())
151 .or(Some(l.to_string()))
147 let ctx = self.context(space_name, repo_name).await?;
148 let mut client = ctx.ks.remote();
149 let job = client
150 .start_remote_push(pb::StartRemotePushRequest {
151 repository: Some(ctx.repository_ref()),
152 remote,
153 refspec: refspec.unwrap_or_default(),
154 force: false,
155 auth: remote_auth(auth.as_ref()),
156 actor: Some(ctx.actor()),
157 idempotency_key: uuid::Uuid::now_v7().to_string(),
152158 })
153 .filter(|l| l.contains("->"))
154 .collect();
155 let rejected: Vec<String> = stdout
156 .lines()
157 .filter(|l| l.starts_with("!"))
158 .map(|l| l.to_string())
159 .collect();
160 if !out.status.success() && pushed.is_empty() {
161 return Err(GitError::Internal(format!(
162 "push failed: {}",
163 String::from_utf8_lossy(&out.stderr).trim()
164 )));
159 .await
160 .map_err(rpc_error)?
161 .into_inner()
162 .job
163 .ok_or_else(|| GitError::Internal("push job missing".into()))?;
164 let job = wait_job(&ctx.ks, &job.id, self.job_timeout()).await?;
165 match job.result {
166 Some(pb::job::Result::Remote(remote)) => Ok(PushResultDto {
167 pushed: remote.pushed,
168 rejected: remote.rejected,
169 }),
170 _ => Err(GitError::Internal("push job returned no result".into())),
165171 }
166 Ok(PushResultDto { pushed, rejected })
167172 }
168173
169174 pub async fn remote_add(
@@ -176,10 +181,18 @@
176181 validate_remote_name(&name)?;
177182 validate_remote_url(&url)?;
178183 let ctx = self.context(space_name, repo_name).await?;
179 self.baby(&ctx)
180 .add_remote(&name, &url, gitbaby::remote::types::RemoteOption::default())
184 let mut client = ctx.ks.remote();
185 client
186 .upsert_remote(pb::UpsertRemoteRequest {
187 repository: Some(ctx.repository_ref()),
188 name,
189 url,
190 actor: Some(ctx.actor()),
191 options: Some(ctx.write_options()),
192 })
181193 .await
182 .map_err(map_baby)
194 .map_err(rpc_error)?;
195 Ok(())
183196 }
184197
185198 pub async fn remote_upsert(
@@ -189,30 +202,7 @@
189202 name: String,
190203 url: String,
191204 ) -> Result<(), GitError> {
192 validate_remote_name(&name)?;
193 validate_remote_url(&url)?;
194 let ctx = self.context(space_name, repo_name).await?;
195 let baby = self.baby(&ctx);
196 if baby
197 .get_remote_address(&name)
198 .await
199 .map_err(map_baby)?
200 .is_some()
201 {
202 run_git_soft(
203 "remotes::set_url",
204 &ctx.repo_root,
205 vec!["remote".to_string(), "set-url".to_string(), name, url],
206 &[],
207 30,
208 )
209 .await?;
210 Ok(())
211 } else {
212 baby.add_remote(&name, &url, gitbaby::remote::types::RemoteOption::default())
213 .await
214 .map_err(map_baby)
215 }
205 self.remote_add(space_name, repo_name, name, url).await
216206 }
217207
218208 pub async fn remote_list(
@@ -221,35 +211,23 @@
221211 repo_name: String,
222212 ) -> Result<Vec<RemoteDto>, GitError> {
223213 let ctx = self.context(space_name, repo_name).await?;
224 let out = run_git_soft(
225 "remotes::list",
226 &ctx.repo_root,
227 vec!["remote".to_string(), "-v".to_string()],
228 &[],
229 30,
230 )
231 .await?;
232 let text = String::from_utf8_lossy(&out.stdout);
233 let mut map: std::collections::HashMap<String, RemoteDto> =
234 std::collections::HashMap::new();
235 for line in text.lines() {
236 let mut parts = line.split_whitespace();
237 let (Some(name), Some(url), Some(kind)) = (parts.next(), parts.next(), parts.next())
238 else {
239 continue;
240 };
241 let entry = map.entry(name.to_string()).or_insert_with(|| RemoteDto {
242 name: name.to_string(),
243 fetch_url: None,
244 push_url: None,
245 });
246 if kind.starts_with("(fetch") {
247 entry.fetch_url = Some(url.to_string());
248 } else if kind.starts_with("(push") {
249 entry.push_url = Some(url.to_string());
250 }
251 }
252 let mut rows: Vec<RemoteDto> = map.into_values().collect();
214 let mut client = ctx.ks.remote();
215 let response = client
216 .list_remotes(pb::ListRemotesRequest {
217 repository: Some(ctx.repository_ref()),
218 })
219 .await
220 .map_err(rpc_error)?
221 .into_inner();
222 let mut rows: Vec<RemoteDto> = response
223 .remotes
224 .into_iter()
225 .map(|remote| RemoteDto {
226 name: remote.name,
227 fetch_url: Some(remote.fetch_url).filter(|url| !url.is_empty()),
228 push_url: Some(remote.push_url).filter(|url| !url.is_empty()),
229 })
230 .collect();
253231 rows.sort_by(|a, b| a.name.cmp(&b.name));
254232 Ok(rows)
255233 }
@@ -262,34 +240,16 @@
262240 ) -> Result<(), GitError> {
263241 validate_remote_name(&name)?;
264242 let ctx = self.context(space_name, repo_name).await?;
265 self.baby(&ctx).remove_remote(&name).await.map_err(map_baby)
266 }
267}
268
269#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
270pub struct RemoteDto {
271 pub name: String,
272 pub fetch_url: Option<String>,
273 pub push_url: Option<String>,
274}
275
276async fn count_refs(dir: &std::path::Path) -> Result<i64, GitError> {
277 let timeout = 60;
278 let out = run_git_soft(
279 "remotes::count_refs",
280 dir,
281 vec![
282 "for-each-ref".to_string(),
283 "--count".to_string(),
284 "--format=%(objectname)".to_string(),
285 ],
286 &[],
287 timeout,
288 )
289 .await?;
290 if out.status.success() {
291 Ok(String::from_utf8_lossy(&out.stdout).lines().count() as i64)
292 } else {
293 Ok(0)
243 let mut client = ctx.ks.remote();
244 client
245 .remove_remote(pb::RemoveRemoteRequest {
246 repository: Some(ctx.repository_ref()),
247 name,
248 actor: Some(ctx.actor()),
249 options: Some(ctx.write_options()),
250 })
251 .await
252 .map_err(rpc_error)?;
253 Ok(())
294254 }
295255}
Modified lib/git/src/service/repos.rs View lib/git/src/service/repos.rs
@@ -1,9 +1,4 @@
1use std::path::Path;
2
3use gitbaby::command::cmd::Cmd;
4use gitbaby::command::env::Env;
5use gitbaby::refs::types::{ListRefsOptions, RefType};
6use gitbaby::setup::types::{FastImportCommit, ObjectFormat};
1use crate::rpcgit::refs::types::{ListRefsOptions, RefType};
72use models::dir::dirs::{self, DirKind};
83use models::repo::repo_collaborations;
94use models::repo::repo_license;
@@ -20,19 +15,13 @@
2015use crate::context::facade::GitContext;
2116use crate::service::GitService;
2217use crate::share::errors::{GitError, map_baby};
23use crate::share::identity::load_identity;
2418use crate::share::validate::{validate_branch_name, validate_repo_name};
2519
2620pub const DEFAULT_BRANCH: &str = "main";
2721
2822/// 仓库页签中可由设置重排序的中间页签(代码固定最前、设置固定最后)。
29pub const REORDERABLE_REPO_TABS: [&str; 5] = [
30 "commits",
31 "pulls",
32 "branches",
33 "pipelines",
34 "releases",
35];
23pub const REORDERABLE_REPO_TABS: [&str; 5] =
24 ["commits", "pulls", "branches", "pipelines", "releases"];
3625
3726/// 归一化页签顺序:只保留已知中间页签、去重、限制长度;空 = 默认顺序。
3827pub fn normalize_repo_tab_order(order: &[String]) -> Vec<String> {
@@ -441,52 +430,20 @@
441430 model: repo.clone(),
442431 cache,
443432 database: self.state.database.clone(),
433 space_name: space.lower_name.clone(),
444434 repo_root: std::path::PathBuf::from(&git_cfg.repo_root),
445 objects_root: std::path::PathBuf::from(&git_cfg.objects_root),
435 ks: self.ks().clone(),
446436 };
447 let repo_dir = ctx.repo_dir();
448437
449438 let init_result: Result<(), GitError> = async {
450 tokio::fs::create_dir_all(&repo_dir)
451 .await
452 .map_err(|e| GitError::Internal(format!("mkdir {}: {e}", repo_dir.display())))?;
453 let baby = self.baby(&ctx);
454 baby.init_repository(&repo_dir.to_string_lossy(), true, Some(ObjectFormat::Sha1))
455 .await
456 .map_err(map_baby)?;
457
458 let identity = load_identity(&self.state.database, caller).await?;
459 baby.config_set("user.name", &identity.name)
460 .await
461 .map_err(map_baby)?;
462 baby.config_set("user.email", &identity.email)
463 .await
464 .map_err(map_baby)?;
465
466 let init_msg = cmd
467 .init_message
468 .unwrap_or_else(|| "Initial commit".to_string());
469 let now = OffsetDateTime::now_utc();
470 let commit = FastImportCommit {
471 ref_name: format!("refs/heads/{default_branch}"),
472 message: Some(init_msg),
473 committer_name: identity.name,
474 committer_email: identity.email,
475 committer_time: now.unix_timestamp(),
476 committer_tz_offset: 0,
477 files: Vec::new(),
478 };
479 baby.fast_import(&repo_dir, vec![commit])
439 self.baby(&ctx)
440 .init_repository("", true, None)
480441 .await
481 .map_err(map_baby)?;
482 Ok(())
442 .map_err(map_baby)
483443 }
484444 .await;
485445
486446 if let Err(err) = init_result {
487 if let Err(e) = tokio::fs::remove_dir_all(&repo_dir).await {
488 tracing::warn!(error = %e, "failed to clean up repo dir after init failure");
489 }
490447 if let Err(e) = repos::Entity::delete_by_id(repo.id)
491448 .exec(self.db_write())
492449 .await
@@ -726,12 +683,14 @@
726683 .filter(|r| r.ref_type == RefType::Branch)
727684 .count() as i32;
728685 let tags_count = refs.iter().filter(|r| r.ref_type == RefType::Tag).count() as i32;
729 let commits_count = count_commits(
730 &ctx.repo_dir(),
731 self.state.config.git.git_command_timeout_secs,
732 )
733 .await? as i32;
734 let git_size = dir_size(&ctx.repo_dir()).await? as i64;
686 let commits_count = baby
687 .count_commits(crate::rpcgit::commit::types::ListCommitsOptions {
688 revision: "--all".to_string(),
689 ..Default::default()
690 })
691 .await
692 .unwrap_or(0) as i32;
693 let git_size = baby.repo_size().await.unwrap_or(0) as i64;
735694
736695 let now = OffsetDateTime::now_utc();
737696 let am = repos::ActiveModel {
@@ -746,50 +705,6 @@
746705 am.update(self.db_write()).await?;
747706 Ok(())
748707 }
749}
750
751pub(crate) async fn count_commits(dir: &Path, timeout: u64) -> Result<i64, GitError> {
752 let mut cmd = Cmd::new(
753 "gitdata::git:count_commits",
754 "git",
755 vec![
756 "rev-list".to_string(),
757 "--count".to_string(),
758 "--all".to_string(),
759 ],
760 Vec::new(),
761 dir.to_path_buf(),
762 Env::new(),
763 Some(timeout),
764 );
765 let output = cmd
766 .run()
767 .await
768 .map_err(|e| GitError::Internal(format!("rev-list failed: {e}")))?;
769 if !output.status.success() {
770 return Err(GitError::Internal(format!(
771 "rev-list failed: {}",
772 String::from_utf8_lossy(&output.stderr).trim()
773 )));
774 }
775 let text = String::from_utf8_lossy(&output.stdout);
776 text.trim()
777 .parse::<i64>()
778 .map_err(|e| GitError::Internal(format!("parse commit count `{text}`: {e}")))
779}
780
781pub(crate) async fn dir_size(path: &Path) -> std::io::Result<u64> {
782 let mut total = 0u64;
783 let mut entries = tokio::fs::read_dir(path).await?;
784 while let Some(entry) = entries.next_entry().await? {
785 let meta = entry.metadata().await?;
786 if meta.is_dir() {
787 total += Box::pin(dir_size(&entry.path())).await?;
788 } else {
789 total += meta.len();
790 }
791 }
792 Ok(total)
793708}
794709
795710#[cfg(test)]
Added lib/git/src/service/rpc.rs View lib/git/src/service/rpc.rs
@@ -0,0 +1,62 @@
1use std::time::Duration;
2
3use kenshell::proto::v1 as pb;
4use tokio::time::sleep;
5
6use crate::share::errors::GitError;
7
8pub(crate) fn rpc_error(status: tonic::Status) -> GitError {
9 match status.code() {
10 tonic::Code::NotFound => GitError::NotFound,
11 tonic::Code::PermissionDenied => GitError::Forbidden,
12 tonic::Code::Unauthenticated => GitError::Unauthorized,
13 tonic::Code::AlreadyExists | tonic::Code::Aborted | tonic::Code::FailedPrecondition => {
14 GitError::Conflict(status.message().to_string())
15 }
16 tonic::Code::InvalidArgument => GitError::InvalidArgument(status.message().to_string()),
17 tonic::Code::Unimplemented => {
18 GitError::Unimplemented("operation is not supported by KenShell")
19 }
20 _ => GitError::Internal(status.message().to_string()),
21 }
22}
23
24pub(crate) async fn wait_job(
25 ks: &kenshell::KenShellClient,
26 job_id: &str,
27 timeout: Duration,
28) -> Result<pb::Job, GitError> {
29 let deadline = tokio::time::Instant::now() + timeout;
30 let mut client = ks.job();
31 loop {
32 let job = client
33 .get_job(pb::GetJobRequest {
34 id: job_id.to_string(),
35 })
36 .await
37 .map_err(rpc_error)?
38 .into_inner()
39 .job
40 .ok_or_else(|| GitError::Internal("job missing".into()))?;
41 match pb::JobState::try_from(job.state).unwrap_or(pb::JobState::Unspecified) {
42 pb::JobState::Succeeded => return Ok(job),
43 pb::JobState::Failed | pb::JobState::Canceled => {
44 return Err(GitError::Internal(if job.error_message.is_empty() {
45 "job failed".to_string()
46 } else {
47 job.error_message
48 }));
49 }
50 _ => {}
51 }
52 if tokio::time::Instant::now() >= deadline {
53 return Err(GitError::Internal("job timed out".into()));
54 }
55 sleep(Duration::from_millis(400)).await;
56 }
57}
58impl crate::service::GitService {
59 pub(crate) fn job_timeout(&self) -> Duration {
60 Duration::from_secs(self.state.config.git.git_command_timeout_secs.max(30))
61 }
62}
Modified lib/git/src/service/signatures.rs View lib/git/src/service/signatures.rs
@@ -1,9 +1,7 @@
11use serde::{Deserialize, Serialize};
22
33use crate::service::GitService;
4use crate::service::cache::{self, IMMUTABLE_TTL};
5use crate::service::gitcmd::run_git_soft;
6use crate::share::errors::{GitError, map_baby};
4use crate::share::errors::GitError;
75
86#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
97pub struct VerifyResultDto {
@@ -17,90 +15,23 @@
1715impl GitService {
1816 pub async fn signature_verify_commit(
1917 &self,
20 space_name: String,
21 repo_name: String,
22 sha: String,
18 _space_name: String,
19 _repo_name: String,
20 _sha: String,
2321 ) -> Result<VerifyResultDto, GitError> {
24 let sha_trim = sha.trim();
25 if !(sha_trim.len() == 40 || sha_trim.len() == 64)
26 || !sha_trim.bytes().all(|b| b.is_ascii_hexdigit())
27 {
28 return Err(GitError::InvalidArgument(format!(
29 "invalid commit sha `{sha}`"
30 )));
31 }
32 let ctx = self.context(space_name, repo_name).await?;
33 let key = format!("repo.{}.sig_verify_commit.{}", ctx.model.id, sha_trim);
34 cache::cached_fetch(&ctx.cache, &key, IMMUTABLE_TTL, || async {
35 let timeout = self.state.config.git.git_command_timeout_secs;
36 let out = run_git_soft(
37 "signatures::verify_commit",
38 &ctx.repo_dir(),
39 vec![
40 "verify-commit".to_string(),
41 "--raw".to_string(),
42 sha_trim.to_string(),
43 ],
44 &[],
45 timeout,
46 )
47 .await?;
48 let stdout = String::from_utf8_lossy(&out.stdout);
49 let mut valid = false;
50 let mut signer: Option<String> = None;
51 let mut key_id: Option<String> = None;
52 for line in stdout.lines() {
53 if line.starts_with("good ") || line.starts_with("valid ") {
54 valid = true;
55 }
56 if let Some(idx) = line.find("signer \"") {
57 let rest = &line[idx + 8..];
58 if let Some(end) = rest.find('"') {
59 signer = Some(rest[..end].to_string());
60 }
61 }
62 if let Some(idx) = line.find("key ") {
63 key_id = Some(line[idx + 4..].trim().to_string());
64 }
65 }
66 let error_message = if !out.status.success() {
67 Some(String::from_utf8_lossy(&out.stderr).trim().to_string())
68 } else {
69 None
70 };
71 Ok(VerifyResultDto {
72 signed: valid || key_id.is_some() || signer.is_some(),
73 valid,
74 key_id,
75 signer,
76 error_message,
77 })
78 })
79 .await
22 Err(GitError::Unimplemented(
23 "commit signature verification requires a KenShell SignatureService",
24 ))
8025 }
8126
8227 pub async fn signature_verify_tag(
8328 &self,
84 space_name: String,
85 repo_name: String,
86 name: String,
29 _space_name: String,
30 _repo_name: String,
31 _name: String,
8732 ) -> Result<VerifyResultDto, GitError> {
88 let ctx = self.context(space_name, repo_name).await?;
89 let key = format!("repo.{}.sig_verify_tag.{}", ctx.model.id, name);
90 cache::cached_fetch(&ctx.cache, &key, IMMUTABLE_TTL, || async {
91 let res = self
92 .baby(&ctx)
93 .verify_tag_signature(&name)
94 .await
95 .map_err(map_baby)?;
96 Ok(VerifyResultDto {
97 signed: res.valid || res.signer.is_some(),
98 valid: res.valid,
99 key_id: res.fingerprint,
100 signer: res.signer,
101 error_message: res.error_message,
102 })
103 })
104 .await
33 Err(GitError::Unimplemented(
34 "tag signature verification requires a KenShell SignatureService",
35 ))
10536 }
10637}
Modified lib/git/src/service/submodules.rs View lib/git/src/service/submodules.rs
@@ -1,6 +1,6 @@
11use std::collections::HashMap;
22
3use gitbaby::commit::types::TreeEntryMode;
3use crate::rpcgit::commit::types::TreeEntryMode;
44use serde::{Deserialize, Serialize};
55
66use crate::service::GitService;
Modified lib/git/src/service/tags.rs View lib/git/src/service/tags.rs
@@ -1,9 +1,8 @@
11use std::collections::HashMap;
2use std::path::Path;
2use std::sync::Arc;
33
4use gitbaby::command::cmd::Cmd;
5use gitbaby::command::env::Env;
6use gitbaby::tags::{CreateTagOptions, ListTagsOptions, TagVerifyResult};
4use crate::rpcgit::tags::types::TagType as GbTagType;
5use crate::rpcgit::tags::{CreateTagOptions, ListTagsOptions, TagVerifyResult};
76use models::git::git_tags;
87use models::git::git_tags::TagType;
98use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
@@ -16,8 +15,6 @@
1615use crate::share::errors::{GitError, map_baby};
1716use crate::share::policy::load_tag_policy;
1817
19const CALLER: &str = "gitdata::git:tags";
20
2118#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
2219pub struct TagDto {
2320 pub name: String,
@@ -46,97 +43,20 @@
4643 tagger_name: Option<String>,
4744 tagger_email: Option<String>,
4845 message: Option<String>,
49}
50
51fn parse_tagger(line: &str) -> Option<(String, String)> {
52 let rest = line.strip_prefix("tagger ")?;
53 let (head, _) = rest.split_once('>')?;
54 let (name, email) = head.split_once(" <")?;
55 Some((name.trim().to_string(), email.trim().to_string()))
56}
57
58fn parse_cat_file(output: &str, ref_oid: &str) -> Option<RawTagMeta> {
59 let mut lines = output.lines();
60 let first = lines.next().unwrap_or("");
61 let Some(target) = first.strip_prefix("object ") else {
62 return Some(RawTagMeta {
63 target_sha: ref_oid.to_string(),
64 is_annotated: false,
65 tagger_name: None,
66 tagger_email: None,
67 message: None,
68 });
69 };
70 let mut tagger_name = None;
71 let mut tagger_email = None;
72 for line in lines.by_ref() {
73 if line.is_empty() {
74 break;
75 }
76 if let Some((n, e)) = parse_tagger(line) {
77 tagger_name = Some(n);
78 tagger_email = Some(e);
79 }
80 }
81 let rest: Vec<&str> = lines.collect();
82 let message = match rest.join("\n").trim() {
83 "" => None,
84 m => Some(m.to_string()),
85 };
86 Some(RawTagMeta {
87 target_sha: target.trim().to_string(),
88 is_annotated: true,
89 tagger_name,
90 tagger_email,
91 message,
92 })
93}
94
95async fn run_git(dir: &Path, args: &[&str]) -> Result<(bool, String, String), GitError> {
96 let mut cmd = Cmd::new(
97 CALLER,
98 "git",
99 args.iter().map(|s| s.to_string()).collect(),
100 Vec::new(),
101 dir.to_path_buf(),
102 Env::new(),
103 None,
104 );
105 let output = cmd
106 .run()
107 .await
108 .map_err(|e| GitError::Internal(format!("git {args:?} failed: {e}")))?;
109 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
110 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
111 Ok((output.status.success(), stdout, stderr))
11246}
11347
11448async fn read_tag_meta(ctx: &GitContext, name: &str) -> Result<RawTagMeta, GitError> {
11549 let key = format!("repo.{}.tag_raw_meta.{}", ctx.model.id, name);
11650 cache::cached_fetch(&ctx.cache, &key, IMMUTABLE_TTL, || async {
117 let dir = ctx.repo_dir();
118 let (ok, stdout, stderr) = run_git(&dir, &["show-ref", "--tags", "--", name]).await?;
119 if !ok {
120 let s = stderr.trim();
121 if s.contains("not found") || stdout.is_empty() {
122 return Err(GitError::NotFound);
123 }
124 return Err(GitError::Internal(format!("show-ref {name} failed: {s}")));
125 }
126 let oid = stdout
127 .split_whitespace()
128 .next()
129 .ok_or(GitError::NotFound)?
130 .to_string();
131 let (ok, stdout, stderr) = run_git(&dir, &["cat-file", "-p", &oid]).await?;
132 if !ok {
133 return Err(GitError::Internal(format!(
134 "cat-file {oid} failed: {}",
135 stderr.trim()
136 )));
137 }
138 parse_cat_file(&stdout, &oid).ok_or_else(|| {
139 GitError::Internal(format!("parse tag object {oid} for `{name}` failed"))
51 let baby = crate::rpcgit::GitBaby::new(Arc::new(ctx.clone()));
52 let tag = baby.find_tag(name).await.map_err(map_baby)?;
53 Ok(RawTagMeta {
54 target_sha: tag.target_sha.to_string(),
55 is_annotated: matches!(tag.tagger, Some(_))
56 || matches!(tag.tag_type, GbTagType::Annotated),
57 tagger_name: tag.tagger.as_ref().map(|t| t.name.clone()),
58 tagger_email: tag.tagger.as_ref().map(|t| t.email.clone()),
59 message: tag.message,
14060 })
14161 })
14262 .await
Modified lib/git/src/service/trees.rs View lib/git/src/service/trees.rs
@@ -1,7 +1,7 @@
11use std::path::PathBuf;
22
3use gitbaby::commit::types::{TreeEntry, TreeEntryMode};
4use gitbaby::tree::types::WalkTreeOptions;
3use crate::rpcgit::commit::types::{TreeEntry, TreeEntryMode};
4use crate::rpcgit::tree::types::WalkTreeOptions;
55use serde::{Deserialize, Serialize};
66
77use crate::service::GitService;
@@ -127,8 +127,8 @@
127127 let baby = self.baby(&ctx);
128128 let blob = baby
129129 .get_blob(
130 entry.oid,
131 gitbaby::blob::types::GetBlobOptions { limit: Some(8192) },
130 entry.oid.clone(),
131 crate::rpcgit::blob::types::GetBlobOptions { limit: Some(8192) },
132132 )
133133 .await
134134 .map_err(map_baby)?;
@@ -188,7 +188,7 @@
188188 &self,
189189 ctx: &std::sync::Arc<crate::context::facade::GitContext>,
190190 revision: &str,
191 ) -> Result<Vec<gitbaby::tree::types::TreeEntryWithLatest>, GitError> {
191 ) -> Result<Vec<crate::rpcgit::tree::types::TreeEntryWithLatest>, GitError> {
192192 validate_revision(revision)?;
193193 if ctx.repo_uninitialized() {
194194 return Ok(Vec::new());
@@ -209,7 +209,7 @@
209209 ctx: &std::sync::Arc<crate::context::facade::GitContext>,
210210 revision: &str,
211211 path: &str,
212 ) -> Result<Vec<gitbaby::tree::types::TreeEntryWithLatest>, GitError> {
212 ) -> Result<Vec<crate::rpcgit::tree::types::TreeEntryWithLatest>, GitError> {
213213 validate_revision(revision)?;
214214 if path.contains("..")
215215 || path.contains('\\')
Modified lib/git/src/share/errors.rs View lib/git/src/share/errors.rs
@@ -1,4 +1,4 @@
1use gitbaby::error::BabyError;
1use crate::rpcgit::error::BabyError;
22
33#[derive(Debug, thiserror::Error)]
44pub enum GitError {
@@ -16,6 +16,8 @@
1616 Forbidden,
1717 #[error("git error: {0}")]
1818 Git(#[from] BabyError),
19 #[error("not implemented: {0}")]
20 Unimplemented(&'static str),
1921 #[error("internal error: {0}")]
2022 Internal(String),
2123}
@@ -65,6 +67,7 @@
6567 Self::Forbidden => StatusCode::FORBIDDEN,
6668 Self::Conflict(_) => StatusCode::CONFLICT,
6769 Self::InvalidArgument(_) => StatusCode::BAD_REQUEST,
70 Self::Unimplemented(_) => StatusCode::NOT_IMPLEMENTED,
6871 Self::Cache(_) | Self::Git(_) | Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
6972 };
7073 let body = match &self {
@@ -72,6 +75,7 @@
7275 tracing::warn!(error = %self, "git internal error");
7376 "internal server error".to_string()
7477 }
78 Self::Unimplemented(_) => self.to_string(),
7579 other => other.to_string(),
7680 };
7781 let mut resp = (status, body).into_response();
Deleted lib/git/src/share/git_env.rs
@@ -1,95 +0,0 @@
1/// Process-wide `git` configuration for the child processes this service spawns.
2///
3/// The forge does not build the argv for those: `gitbaby` does, and it parses
4/// path names out of `git ls-tree` **text** output. See [`init`] for why one of
5/// those settings has to be forced from the environment.
6use std::sync::Once;
7
8static INIT: Once = Once::new();
9
10/// Force `core.quotepath=off` for every `git` child process of this service.
11///
12/// With the default `core.quotePath=true`, git prints any path holding a byte it
13/// considers non-printable as a quoted, octal-escaped C string: `说明.md` comes
14/// back over the pipe as the literal `"\350\257\264\346\230\216.md"`, **quotes
15/// included**. `gitbaby`'s `ls-tree` parser takes everything after the tab
16/// verbatim as the entry name, so that quoting becomes the name the whole forge
17/// then works with — the directory listing, the breadcrumb, the blob and history
18/// lookups, and the JSON tree API. It is visible most clearly on markdown: an
19/// opened `说明.md` no longer ends in `.md`, so the viewer stops treating it as a
20/// document and shows it as source.
21///
22/// `gitbaby` already passes `-c core.quotepath=off` for the two commands that
23/// report paths (`log` for last-commit and path history); `ls-tree` is not one of
24/// them and its argv is not ours to change. Environment config is the supported
25/// way to reach a subprocess we do not build arguments for: git reads
26/// `GIT_CONFIG_KEY_<n>` / `GIT_CONFIG_VALUE_<n>` with the same precedence as `-c`
27/// (later entries win), and `gitbaby`'s `build_env` forwards the whole process
28/// environment to the child. It also survives `GIT_CONFIG_NOSYSTEM`, which only
29/// suppresses the system config *file* — verified against both.
30///
31/// Operator-supplied `GIT_CONFIG_*` slots are preserved: this appends one more
32/// rather than resetting the count, so ours simply takes precedence. If the
33/// count is already present but unreadable the environment is not ours to guess
34/// at, and nothing is changed.
35///
36/// # Safety
37///
38/// `std::env::set_var` races with concurrent environment reads, so it is called
39/// exactly once, from [`crate::service::GitService::new`], which every host runs
40/// while it is still wiring itself up — before any listener is bound.
41pub fn init() {
42 INIT.call_once(|| {
43 let count = match std::env::var("GIT_CONFIG_COUNT") {
44 Err(_) => 0,
45 Ok(raw) => match raw.trim().parse::<usize>() {
46 Ok(value) => value,
47 Err(_) => {
48 tracing::warn!(
49 value = raw.as_str(),
50 "GIT_CONFIG_COUNT is not a number; leaving git config env untouched"
51 );
52 return;
53 }
54 },
55 };
56 let key_slot = format!("GIT_CONFIG_KEY_{count}");
57 unsafe {
58 std::env::set_var(&key_slot, "core.quotepath");
59 std::env::set_var(format!("GIT_CONFIG_VALUE_{count}"), "off");
60 std::env::set_var("GIT_CONFIG_COUNT", (count + 1).to_string());
61 }
62 tracing::debug!(
63 slot = key_slot.as_str(),
64 "forced core.quotepath=off for git children"
65 );
66 });
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 /// What the fix actually promises is about a *child* process, so ask one:
74 /// `git config --get` reads the same environment slots `ls-tree` will, with
75 /// no repository involved. If this returns `off`, the quoting is really gone
76 /// for every git command `gitbaby` runs on our behalf.
77 #[test]
78 fn child_git_sees_quotepath_off() {
79 init();
80 let probe = std::process::Command::new("git")
81 .args(["config", "--get", "core.quotepath"])
82 .output()
83 .expect("git on PATH");
84 assert!(
85 probe.status.success(),
86 "`git config --get core.quotepath` failed: {}",
87 String::from_utf8_lossy(&probe.stderr)
88 );
89 assert_eq!(
90 String::from_utf8_lossy(&probe.stdout).trim(),
91 "off",
92 "the appended GIT_CONFIG slot must reach the child"
93 );
94 }
95}
Modified lib/git/src/share/identity.rs View lib/git/src/share/identity.rs
@@ -1,7 +1,6 @@
11use models::users::user;
22use models::users::user_emails;
33use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
4use uuid::Uuid;
54
65use crate::share::errors::GitError;
76
Modified lib/git/src/share/mod.rs View lib/git/src/share/mod.rs
@@ -1,6 +1,5 @@
11pub mod access;
22pub mod errors;
3pub mod git_env;
43pub mod identity;
54pub mod policy;
65pub mod validate;
Deleted lib/git/src/smart/dispatch.rs
@@ -1,256 +0,0 @@
1use std::sync::Arc;
2
3use axum::body::Body;
4use axum::http::{HeaderMap, StatusCode, header};
5use axum::response::{IntoResponse, Response};
6use bytes::Bytes;
7use flate2::read::GzDecoder;
8use futures::StreamExt as _;
9use std::io::Read;
10use uuid::Uuid;
11
12use crate::share::access::{BasicAuth, allow_repo_read, require_writer};
13use crate::smart::error::{SmartError, SmartResult};
14use crate::smart::info_refs as info_refs_module;
15use crate::smart::pre_receive::{self, parse_receive_pack_refs};
16use crate::smart::rpc::{self, GitService};
17use crate::smart::service::SmartService;
18use crate::sync::post_receive::enqueue_post_receive_push_with_ci_logged;
19
20const MAX_RPC_BODY: usize = 512 * 1024 * 1024;
21
22#[tracing::instrument(name = "git.info_refs", skip_all, fields(space, repo))]
23pub async fn info_refs(
24 service: &SmartService,
25 space: &str,
26 repo: &str,
27 _headers: &HeaderMap,
28 auth: &BasicAuth,
29 service_kind: Option<&str>,
30) -> SmartResult<Response> {
31 let (ctx, _actor_id) = match service_kind {
32 Some(name) => {
33 let git_service = GitService::from_name(name)?;
34 let want_write = matches!(git_service, GitService::ReceivePack);
35 let ctx = service.resolve(space, repo).await?;
36 let actor_id = authorize(service, &ctx, auth, want_write).await?;
37 (ctx, actor_id)
38 }
39 None => {
40 let ctx = service.resolve(space, repo).await?;
41 allow_repo_read(&service.git, auth, &ctx).await?;
42 (ctx, None)
43 }
44 };
45 let bare = service.bare_path(&ctx);
46 tracing::debug!(
47 space,
48 repo,
49 service = service_kind.unwrap_or("dumb"),
50 bare = %bare.display(),
51 "info/refs authorized, generating advertisement"
52 );
53 match service_kind {
54 Some(name) => {
55 let git_service = GitService::from_name(name)?;
56 let timeout = service.git.state.config.git.git_rpc_timeout_secs;
57 info_refs_module::smart_advertisement(&bare, git_service, timeout).await
58 }
59 None => info_refs_module::dumb_info_refs(&bare).await,
60 }
61}
62
63#[allow(clippy::too_many_arguments)]
64#[tracing::instrument(name = "git.rpc", skip_all, fields(space, repo, op = ?git_service))]
65pub async fn rpc(
66 service: &SmartService,
67 space: &str,
68 repo: &str,
69 headers: &HeaderMap,
70 body: Body,
71 auth: &BasicAuth,
72 git_service: GitService,
73) -> SmartResult<Response> {
74 let want_write = matches!(git_service, GitService::ReceivePack);
75 let ctx = service.resolve(space, repo).await?;
76 let actor_id = authorize(service, &ctx, auth, want_write).await?;
77 let bare = service.bare_path(&ctx);
78 let space_id = service.owning_space_id(&ctx).await?;
79 tracing::info!(
80 space,
81 repo,
82 service = git_service.name(),
83 actor_id = ?actor_id,
84 repo_id = %ctx.model.id,
85 "git rpc authorized, processing request"
86 );
87
88 let run = async {
89 let input = decode_request_body(headers, body, MAX_RPC_BODY).await?;
90 let is_receive = git_service == GitService::ReceivePack;
91 tracing::debug!(
92 service = git_service.name(),
93 body_bytes = input.len(),
94 "rpc request body decoded"
95 );
96 if is_receive && actor_id.is_some() {
97 let updates = parse_receive_pack_refs(&input)?;
98 tracing::debug!(
99 repo_id = %ctx.model.id,
100 ref_count = updates.len(),
101 "parsed receive-pack ref updates"
102 );
103 if !updates.is_empty()
104 && let Some(actor_uid) = actor_id
105 {
106 let baby = service.git.baby(&ctx);
107 let verdict = pre_receive::validate_pre_receive(
108 service.git.db_read(),
109 &baby,
110 ctx.model.id,
111 actor_uid,
112 &updates,
113 )
114 .await?;
115 verdict.accepted_only()?;
116 tracing::debug!(repo_id = %ctx.model.id, "pre-receive validation passed");
117 }
118 if let Err(e) = crate::sync::post_receive::prepare_post_receive(&bare) {
119 tracing::warn!(?e, "post-receive hook setup failed");
120 }
121 }
122 let timeout = service.git.state.config.git.git_rpc_timeout_secs;
123 let stream = rpc::run_rpc_stream(&bare, git_service, false, input, timeout).await?;
124 let post = PostReceiveStream {
125 is_receive,
126 queue: service.queue.clone(),
127 settings: service.sync_settings.clone(),
128 ci_topic: service.ci_event_topic().map(|t| t.to_string()),
129 space_id,
130 repo_id: ctx.model.id,
131 actor_id,
132 bare: bare.clone(),
133 };
134 let stream = stream.map(|chunk| chunk.map_err(|e| axum::Error::new(e.to_string())));
135 let stream = body_stream_with_post_receive(stream, post);
136 Ok::<_, SmartError>(
137 (
138 StatusCode::OK,
139 [
140 (header::CONTENT_TYPE, git_service.result_content_type()),
141 (header::CACHE_CONTROL, "no-cache"),
142 ],
143 Body::from_stream(stream),
144 )
145 .into_response(),
146 )
147 };
148
149 match git_service {
150 GitService::ReceivePack => {
151 let key = format!("push:{}:{}", space_id, ctx.model.id);
152 service.push_coordinator().run(&key, run).await
153 }
154 GitService::UploadPack => run.await,
155 }
156}
157
158async fn authorize(
159 service: &SmartService,
160 ctx: &crate::context::facade::GitContext,
161 auth: &BasicAuth,
162 want_write: bool,
163) -> SmartResult<Option<i64>> {
164 let svc = &service.git;
165 if want_write {
166 let uid = require_writer(svc, auth, ctx).await?;
167 Ok(Some(uid))
168 } else {
169 let uid = allow_repo_read(svc, auth, ctx).await?;
170 Ok(uid)
171 }
172}
173
174/// Everything the post-receive hook needs once the response body has been
175/// streamed through. A named struct rather than a nine-element tuple: the fields
176/// are all `Uuid`-shaped, so positional access silently swaps tenant and repo.
177pub struct PostReceiveStream {
178 pub is_receive: bool,
179 pub queue: Option<Arc<crate::sync::queue::SyncQueue>>,
180 pub settings: config::git::SyncSettings,
181 pub ci_topic: Option<String>,
182 pub space_id: Uuid,
183 pub repo_id: Uuid,
184 pub actor_id: Option<i64>,
185 pub bare: std::path::PathBuf,
186}
187
188fn body_stream_with_post_receive(
189 stream: impl futures::Stream<Item = Result<Bytes, axum::Error>> + Send + 'static,
190 post: PostReceiveStream,
191) -> impl futures::Stream<Item = Result<Bytes, axum::Error>> + Send + 'static {
192 use futures::{StreamExt, stream};
193 stream.chain(stream::once(async move {
194 let PostReceiveStream {
195 is_receive,
196 queue,
197 settings,
198 ci_topic,
199 space_id,
200 repo_id,
201 actor_id,
202 bare,
203 } = post;
204 if is_receive && let Some(queue) = queue {
205 enqueue_post_receive_push_with_ci_logged(
206 &queue,
207 &settings,
208 ci_topic.as_deref(),
209 &crate::sync::PushTarget {
210 space_id,
211 repo_id,
212 actor_id,
213 bare: &bare,
214 },
215 )
216 .await;
217 }
218 Ok::<_, axum::Error>(Bytes::new())
219 }))
220}
221
222pub async fn decode_request_body(
223 headers: &HeaderMap,
224 body: Body,
225 limit: usize,
226) -> SmartResult<Bytes> {
227 let bytes = axum::body::to_bytes(body, limit)
228 .await
229 .map_err(|e| SmartError::BadRequest(format!("failed to read request body: {e}")))?;
230 let encoding = headers
231 .get(header::CONTENT_ENCODING)
232 .and_then(|v| v.to_str().ok())
233 .map(|v| v.to_ascii_lowercase());
234 if encoding.as_deref() == Some("gzip") {
235 let decoder = GzDecoder::new(bytes.as_ref());
236 let mut out = Vec::new();
237 let cap = limit as u64 + 1;
238 decoder
239 .take(cap)
240 .read_to_end(&mut out)
241 .map_err(|e| SmartError::BadRequest(format!("failed to decompress gzip body: {e}")))?;
242 if out.len() as u64 > limit as u64 {
243 return Err(SmartError::BadRequest(format!(
244 "decompressed body exceeds limit of {limit} bytes"
245 )));
246 }
247 tracing::debug!(
248 compressed = bytes.len(),
249 decompressed = out.len(),
250 "gzip request body decompressed"
251 );
252 Ok(Bytes::from(out))
253 } else {
254 Ok(bytes)
255 }
256}
Deleted lib/git/src/smart/error.rs
@@ -1,80 +0,0 @@
1use axum::http::StatusCode;
2use axum::response::{IntoResponse, Response};
3
4#[derive(Debug, thiserror::Error)]
5pub enum SmartError {
6 #[error("{0}")]
7 BadRequest(String),
8 #[error("unauthorized")]
9 Unauthorized,
10 #[error("{0}")]
11 Forbidden(String),
12 #[error("{0}")]
13 NotFound(String),
14 #[error("{0}")]
15 Conflict(String),
16 #[error("{0}")]
17 Internal(String),
18 #[error("io error: {0}")]
19 Io(#[from] std::io::Error),
20 #[error("database error: {0}")]
21 Db(#[from] sea_orm::DbErr),
22 #[error("redis error: {0}")]
23 Redis(#[from] redis::RedisError),
24}
25
26pub type SmartResult<T> = Result<T, SmartError>;
27
28impl SmartError {
29 pub fn status(&self) -> StatusCode {
30 match self {
31 Self::BadRequest(_) => StatusCode::BAD_REQUEST,
32 Self::Unauthorized => StatusCode::UNAUTHORIZED,
33 Self::Forbidden(_) => StatusCode::FORBIDDEN,
34 Self::NotFound(_) => StatusCode::NOT_FOUND,
35 Self::Conflict(_) => StatusCode::CONFLICT,
36 _ => StatusCode::INTERNAL_SERVER_ERROR,
37 }
38 }
39
40 pub fn message(&self) -> String {
41 match self {
42 Self::BadRequest(m) | Self::Forbidden(m) | Self::NotFound(m) | Self::Conflict(m) => {
43 m.clone()
44 }
45 Self::Internal(m) => {
46 tracing::warn!(error = %m, "smart http internal error");
47 "internal server error".to_string()
48 }
49 other => other.to_string(),
50 }
51 }
52}
53
54impl From<crate::share::errors::GitError> for SmartError {
55 fn from(err: crate::share::errors::GitError) -> Self {
56 use crate::share::errors::GitError;
57 match err {
58 GitError::Unauthorized => Self::Unauthorized,
59 GitError::NotFound => Self::NotFound("resource not found".into()),
60 GitError::Forbidden => Self::Forbidden("forbidden".into()),
61 GitError::InvalidArgument(m) => Self::BadRequest(m),
62 GitError::Conflict(m) => Self::Conflict(m),
63 other => Self::Internal(other.to_string()),
64 }
65 }
66}
67
68impl IntoResponse for SmartError {
69 fn into_response(self) -> Response {
70 let status = self.status();
71 let mut resp = (status, self.message()).into_response();
72 if status == StatusCode::UNAUTHORIZED {
73 resp.headers_mut().insert(
74 axum::http::header::WWW_AUTHENTICATE,
75 axum::http::HeaderValue::from_static("Basic realm=\"gitdata\""),
76 );
77 }
78 resp
79 }
80}
Deleted lib/git/src/smart/handler.rs
@@ -1,192 +0,0 @@
1use std::collections::HashMap;
2
3use axum::Router;
4use axum::body::Body;
5use axum::extract::{Path, Query, Request, State};
6use axum::http::{HeaderMap, Method};
7use axum::response::{IntoResponse, Response};
8
9use crate::share::access::{BasicAuth, allow_repo_read};
10use crate::smart::dispatch;
11use crate::smart::error::{SmartError, SmartResult};
12use crate::smart::lfs;
13use crate::smart::object;
14use crate::smart::rpc::GitService;
15use crate::smart::service::SmartService;
16
17pub fn router(service: SmartService) -> Router {
18 Router::new()
19 .route(
20 "/{space}/{repo}/{*path}",
21 axum::routing::get(dispatch)
22 .post(dispatch)
23 .put(dispatch)
24 .head(dispatch),
25 )
26 .with_state(service)
27 .layer(export::telemetry::http_trace_layer())
28 .layer(export::telemetry::metrics_layer())
29 .layer(export::middleware::AppTraceLayer::new())
30}
31
32async fn dispatch(
33 State(service): State<SmartService>,
34 Path((space, repo, path)): Path<(String, String, String)>,
35 Query(query): Query<HashMap<String, String>>,
36 headers: HeaderMap,
37 auth: BasicAuth,
38 req: Request<Body>,
39) -> Response {
40 let method = req.method().clone();
41 let body = req.into_body();
42
43 tracing::debug!(
44 method = %method,
45 space = %space,
46 repo = %repo,
47 path = %path,
48 "smart http request received"
49 );
50
51 // git 客户端(含 actions/checkout)既可能访问 /<space>/<repo>.git,也可能省略
52 // .git 后缀,两种形态都要落到同一个仓库。
53 let repo_name = repo.strip_suffix(".git").unwrap_or(&repo).to_string();
54 if repo_name.is_empty() {
55 return SmartError::BadRequest("expected '<repo>.git' in URL".into()).into_response();
56 }
57
58 let path_lower = path.to_lowercase();
59 let result: SmartResult<Response> = match (path_lower.as_str(), method.clone()) {
60 ("info/refs", m) if m == Method::GET || m == Method::HEAD => {
61 let service_kind = query.get("service").cloned();
62 match dispatch::info_refs(
63 &service,
64 &space,
65 &repo_name,
66 &headers,
67 &auth,
68 service_kind.as_deref(),
69 )
70 .await
71 {
72 Ok(mut resp) => {
73 if m == Method::HEAD {
74 *resp.body_mut() = Body::empty();
75 }
76 Ok(resp)
77 }
78 Err(e) => Err(e),
79 }
80 }
81 ("git-upload-pack", Method::POST) => {
82 dispatch::rpc(
83 &service,
84 &space,
85 &repo_name,
86 &headers,
87 body,
88 &auth,
89 GitService::UploadPack,
90 )
91 .await
92 }
93 ("git-receive-pack", Method::POST) => {
94 dispatch::rpc(
95 &service,
96 &space,
97 &repo_name,
98 &headers,
99 body,
100 &auth,
101 GitService::ReceivePack,
102 )
103 .await
104 }
105 (p, _) if p.starts_with("info/lfs/") => {
106 lfs::dispatch(
107 &service,
108 &space,
109 &repo_name,
110 &headers,
111 p,
112 method.clone(),
113 body,
114 &auth,
115 )
116 .await
117 }
118 (_, Method::GET) => {
119 let resp = match service.resolve(&space, &repo_name).await {
120 Ok(ctx) => match allow_repo_read(&service.git, &auth, &ctx).await {
121 Ok(_) => {
122 if !is_served_path(&path) {
123 SmartError::Forbidden("path not served".into()).into_response()
124 } else {
125 let bare = service.bare_path(&ctx);
126 match object::serve(&bare, &path).await {
127 Ok(resp) => resp,
128 Err(e) => e.into_response(),
129 }
130 }
131 }
132 Err(e) => e.into_response(),
133 },
134 Err(e) => e.into_response(),
135 };
136 Ok(resp)
137 }
138 _ => Ok(SmartError::BadRequest("method not allowed".into()).into_response()),
139 };
140 let outcome = if result.is_ok() { "ok" } else { "error" };
141 export::metrics::git_op(op_of(&path_lower), outcome);
142 result.unwrap_or_else(|e| {
143 tracing::warn!(
144 method = %method,
145 space = %space,
146 repo = %repo_name,
147 path = %path,
148 error = %e,
149 "smart http request failed"
150 );
151 e.into_response()
152 })
153}
154
155fn op_of(path: &str) -> &'static str {
156 match path {
157 "info/refs" => "info_refs",
158 "git-upload-pack" => "upload_pack",
159 "git-receive-pack" => "receive_pack",
160 p if p.starts_with("info/lfs/") => "lfs",
161 _ => "objects",
162 }
163}
164
165/// Whitelist of paths the GET fallback may serve. The dumb HTTP protocol
166/// fetches `info/refs`, `HEAD`, `objects/info/packs`, `objects/pack/*`
167/// and `objects/<xx>/<rest>`; everything else (notably `config`,
168/// `hooks/*`, `packed-refs`, `info/exclude`) is internal and must not be
169/// exposed anonymously.
170fn is_served_path(path: &str) -> bool {
171 if path == "info/refs" || path == "HEAD" {
172 return true;
173 }
174 if let Some(rest) = path.strip_prefix("objects/") {
175 if rest.starts_with("pack/") {
176 return true;
177 }
178 if rest == "info/packs" || rest.starts_with("info/") {
179 return true;
180 }
181 // objects/<2-char dir>/<38 hex> = loose object fetch
182 if rest.len() >= 3 && rest.as_bytes()[2] == b'/' && rest.is_ascii() {
183 let prefix = &rest[..2];
184 let after = &rest[3..];
185 if prefix.chars().all(|c| c.is_ascii_hexdigit()) && !after.is_empty() {
186 return true;
187 }
188 }
189 return false;
190 }
191 false
192}
Deleted lib/git/src/smart/info_refs.rs
@@ -1,49 +0,0 @@
1use axum::http::{StatusCode, header};
2use axum::response::{IntoResponse, Response};
3use bytes::Bytes;
4
5use crate::smart::error::{SmartError, SmartResult};
6use crate::smart::pktline;
7use crate::smart::rpc::{self, GitService};
8
9pub async fn smart_advertisement(
10 bare: &std::path::Path,
11 service: GitService,
12 timeout_secs: u64,
13) -> SmartResult<Response> {
14 let output = rpc::run_rpc_bytes(bare, service, true, Bytes::new(), timeout_secs).await?;
15 let mut body = pktline::encode_header(service.dashed_name())?;
16 body.extend_from_slice(&output);
17 tracing::debug!(
18 svc = service.name(),
19 bare = %bare.display(),
20 body_bytes = body.len(),
21 "smart ref advertisement generated"
22 );
23
24 Ok((
25 StatusCode::OK,
26 [
27 (header::CONTENT_TYPE, service.advertisement_content_type()),
28 (header::CACHE_CONTROL, "no-cache"),
29 ],
30 body,
31 )
32 .into_response())
33}
34
35pub async fn dumb_info_refs(bare: &std::path::Path) -> SmartResult<Response> {
36 let file = bare.join("info").join("refs");
37 match tokio::fs::read(&file).await {
38 Ok(bytes) => Ok((
39 StatusCode::OK,
40 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
41 bytes,
42 )
43 .into_response()),
44 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
45 Err(SmartError::NotFound("info/refs not found".into()))
46 }
47 Err(e) => Err(SmartError::Io(e)),
48 }
49}
Deleted lib/git/src/smart/lfs.rs
@@ -1,310 +0,0 @@
1use axum::body::Body;
2use axum::http::{HeaderMap, Method, StatusCode, header};
3use axum::response::{IntoResponse, Response};
4use futures::StreamExt as _;
5use serde_json::json;
6use tokio_util::io::StreamReader;
7
8use crate::lfs::types::{
9 LfsBatchRequest, LfsLockCreateRequest, LfsOperation, LfsVerifyLocksRequest,
10};
11use crate::lfs::{lock, object};
12use crate::share::access::{BasicAuth, allow_repo_read, require_writer};
13use crate::smart::error::{SmartError, SmartResult};
14use crate::smart::service::SmartService;
15
16const MAX_LFS_BODY: usize = 64 * 1024 * 1024;
17
18#[allow(clippy::too_many_arguments)]
19pub async fn dispatch(
20 service: &SmartService,
21 space: &str,
22 repo: &str,
23 headers: &HeaderMap,
24 path: &str,
25 method: Method,
26 body: Body,
27 auth: &BasicAuth,
28) -> SmartResult<Response> {
29 tracing::debug!(space, repo, path, method = %method, "lfs request received");
30 match path {
31 "info/lfs/objects/batch" if method == Method::POST => {
32 let bytes = read_body(headers, body, MAX_LFS_BODY).await?;
33 let req: LfsBatchRequest = serde_json::from_slice(&bytes)
34 .map_err(|e| SmartError::BadRequest(format!("invalid batch request: {e}")))?;
35 batch(service, space, repo, req, auth).await
36 }
37 "info/lfs/verify" if method == Method::POST => {
38 let bytes = read_body(headers, body, MAX_LFS_BODY).await?;
39 verify(service, space, repo, bytes, auth).await
40 }
41 p if p.starts_with("info/lfs/objects/") => {
42 let oid = &p["info/lfs/objects/".len()..];
43 match method {
44 Method::PUT => {
45 upload(service, space, repo, oid.to_string(), headers, body, auth).await
46 }
47 Method::GET => download(service, space, repo, oid, auth).await,
48 _ => Ok(SmartError::BadRequest("method not allowed".into()).into_response()),
49 }
50 }
51 "info/lfs/locks" if method == Method::GET => list_locks(service, space, repo, auth).await,
52 "info/lfs/locks" if method == Method::POST => {
53 create_lock(service, space, repo, body, auth).await
54 }
55 "info/lfs/locks/verify" if method == Method::POST => {
56 verify_locks(service, space, repo, body, auth).await
57 }
58 p if p.starts_with("info/lfs/locks/")
59 && p.ends_with("/unlock")
60 && method == Method::POST =>
61 {
62 let id = &p["info/lfs/locks/".len()..p.len() - "/unlock".len()];
63 unlock(service, space, repo, id.to_string(), auth).await
64 }
65 _ => Ok(SmartError::NotFound("lfs endpoint not found".into()).into_response()),
66 }
67}
68
69async fn batch(
70 service: &SmartService,
71 space: &str,
72 repo: &str,
73 req: LfsBatchRequest,
74 auth: &BasicAuth,
75) -> SmartResult<Response> {
76 let svc = &service.git;
77 let ctx = service.resolve(space, repo).await?;
78 match req.operation {
79 LfsOperation::Upload | LfsOperation::Verify => {
80 require_writer(svc, auth, &ctx).await?;
81 }
82 LfsOperation::Download => {
83 allow_repo_read(svc, auth, &ctx).await?;
84 }
85 }
86 tracing::debug!(
87 space,
88 repo,
89 operation = ?req.operation,
90 objects = req.objects.len(),
91 "lfs batch authorized"
92 );
93 let resp = object::batch(svc, space.to_string(), repo.to_string(), req).await?;
94 tracing::debug!(space, repo, "lfs batch processed");
95 Ok((
96 StatusCode::OK,
97 [(header::CONTENT_TYPE, "application/vnd.git-lfs+json")],
98 serde_json::to_vec(&resp).map_err(|e| SmartError::Internal(format!("serialize: {e}")))?,
99 )
100 .into_response())
101}
102
103async fn verify(
104 service: &SmartService,
105 space: &str,
106 repo: &str,
107 bytes: bytes::Bytes,
108 auth: &BasicAuth,
109) -> SmartResult<Response> {
110 let svc = &service.git;
111 let req: crate::lfs::types::LfsVerifyRequest = serde_json::from_slice(&bytes)
112 .map_err(|e| SmartError::BadRequest(format!("invalid verify request: {e}")))?;
113 let ctx = service.resolve(space, repo).await?;
114 require_writer(svc, auth, &ctx).await?;
115 object::verify(svc, space.to_string(), repo.to_string(), req).await?;
116 Ok(StatusCode::OK.into_response())
117}
118
119#[allow(clippy::too_many_arguments)]
120async fn upload(
121 service: &SmartService,
122 space: &str,
123 repo: &str,
124 oid: String,
125 headers: &HeaderMap,
126 body: Body,
127 auth: &BasicAuth,
128) -> SmartResult<Response> {
129 let svc = &service.git;
130 let ctx = service.resolve(space, repo).await?;
131 let uid = require_writer(svc, auth, &ctx).await?;
132 let canonical = crate::lfs::canonical_oid(&oid)?;
133 let size = headers
134 .get(header::CONTENT_LENGTH)
135 .and_then(|v| v.to_str().ok())
136 .and_then(|s| s.parse::<i64>().ok())
137 .unwrap_or(0);
138 let content_type = headers
139 .get(header::CONTENT_TYPE)
140 .and_then(|v| v.to_str().ok())
141 .map(str::to_string);
142 let stream = body
143 .into_data_stream()
144 .map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string())));
145 let reader = StreamReader::new(stream);
146 tracing::debug!(space, repo, oid = %canonical, size, "lfs object upload started");
147 object::upload_stream(
148 svc,
149 space.to_string(),
150 repo.to_string(),
151 uid,
152 canonical,
153 size,
154 content_type,
155 reader,
156 )
157 .await?;
158 tracing::debug!(space, repo, "lfs object upload completed");
159 Ok(StatusCode::OK.into_response())
160}
161
162async fn download(
163 service: &SmartService,
164 space: &str,
165 repo: &str,
166 oid: &str,
167 auth: &BasicAuth,
168) -> SmartResult<Response> {
169 let svc = &service.git;
170 let ctx = service.resolve(space, repo).await?;
171 allow_repo_read(svc, auth, &ctx).await?;
172 let canonical = crate::lfs::canonical_oid(oid)?;
173 let key = format!("lfs/{}/{}", ctx.model.id.simple(), canonical);
174 tracing::debug!(space, repo, oid = %canonical, "lfs object download started");
175 let stream = svc
176 .state
177 .storage
178 .client()
179 .get_stream(&key)
180 .await
181 .map_err(|e| SmartError::Internal(format!("lfs get: {e}")))?;
182 let body = Body::from_stream(
183 stream.map(|chunk| chunk.map_err(|e| -> axum::Error { axum::Error::new(e.to_string()) })),
184 );
185 Ok((
186 StatusCode::OK,
187 [(header::CONTENT_TYPE, "application/octet-stream")],
188 body,
189 )
190 .into_response())
191}
192
193async fn list_locks(
194 service: &SmartService,
195 space: &str,
196 repo: &str,
197 auth: &BasicAuth,
198) -> SmartResult<Response> {
199 let svc = &service.git;
200 let ctx = service.resolve(space, repo).await?;
201 allow_repo_read(svc, auth, &ctx).await?;
202 let resp = lock::list(svc, space.to_string(), repo.to_string(), None, 100).await?;
203 Ok((
204 StatusCode::OK,
205 [(header::CONTENT_TYPE, "application/vnd.git-lfs+json")],
206 serde_json::to_vec(&resp).map_err(|e| SmartError::Internal(format!("serialize: {e}")))?,
207 )
208 .into_response())
209}
210
211async fn create_lock(
212 service: &SmartService,
213 space: &str,
214 repo: &str,
215 body: Body,
216 auth: &BasicAuth,
217) -> SmartResult<Response> {
218 let svc = &service.git;
219 let ctx = service.resolve(space, repo).await?;
220 let uid = require_writer(svc, auth, &ctx).await?;
221 let bytes = axum::body::to_bytes(body, MAX_LFS_BODY)
222 .await
223 .map_err(|e| SmartError::BadRequest(format!("read body: {e}")))?;
224 let req: LfsLockCreateRequest = serde_json::from_slice(&bytes)
225 .map_err(|e| SmartError::BadRequest(format!("invalid lock request: {e}")))?;
226 let lock = crate::lfs::create_lock(
227 svc,
228 space.to_string(),
229 repo.to_string(),
230 uid,
231 req.path,
232 None,
233 )
234 .await?;
235 tracing::debug!(space, repo, lock_id = %lock.id, "lfs lock created");
236 Ok((
237 StatusCode::CREATED,
238 [(header::CONTENT_TYPE, "application/vnd.git-lfs+json")],
239 serde_json::to_vec(&json!({ "lock": lock }))
240 .map_err(|e| SmartError::Internal(format!("serialize: {e}")))?,
241 )
242 .into_response())
243}
244
245async fn verify_locks(
246 service: &SmartService,
247 space: &str,
248 repo: &str,
249 body: Body,
250 auth: &BasicAuth,
251) -> SmartResult<Response> {
252 let svc = &service.git;
253 let ctx = service.resolve(space, repo).await?;
254 let uid = allow_repo_read(svc, auth, &ctx).await?;
255 let uid = uid.ok_or(SmartError::Unauthorized)?;
256 let bytes = axum::body::to_bytes(body, MAX_LFS_BODY)
257 .await
258 .map_err(|e| SmartError::BadRequest(format!("read body: {e}")))?;
259 let req: LfsVerifyLocksRequest = serde_json::from_slice(&bytes)
260 .map_err(|e| SmartError::BadRequest(format!("invalid request: {e}")))?;
261 let resp = lock::verify_locks(
262 svc,
263 space.to_string(),
264 repo.to_string(),
265 uid,
266 req.limit.unwrap_or(100) as u64,
267 )
268 .await?;
269 Ok((
270 StatusCode::OK,
271 [(header::CONTENT_TYPE, "application/vnd.git-lfs+json")],
272 serde_json::to_vec(&resp).map_err(|e| SmartError::Internal(format!("serialize: {e}")))?,
273 )
274 .into_response())
275}
276
277async fn unlock(
278 service: &SmartService,
279 space: &str,
280 repo: &str,
281 lock_id: String,
282 auth: &BasicAuth,
283) -> SmartResult<Response> {
284 let svc = &service.git;
285 let ctx = service.resolve(space, repo).await?;
286 let uid = require_writer(svc, auth, &ctx).await?;
287 tracing::debug!(space, repo, lock_id = %lock_id, "lfs unlock requested");
288 crate::lfs::unlock_by_id(
289 svc,
290 space.to_string(),
291 repo.to_string(),
292 uid,
293 lock_id,
294 false,
295 )
296 .await?;
297 Ok((
298 StatusCode::OK,
299 [(header::CONTENT_TYPE, "application/vnd.git-lfs+json")],
300 serde_json::to_vec(&json!({ "message": "unlock successful" }))
301 .map_err(|e| SmartError::Internal(format!("serialize: {e}")))?,
302 )
303 .into_response())
304}
305
306async fn read_body(_headers: &HeaderMap, body: Body, limit: usize) -> SmartResult<bytes::Bytes> {
307 axum::body::to_bytes(body, limit)
308 .await
309 .map_err(|e| SmartError::BadRequest(format!("failed to read body: {e}")))
310}
Deleted lib/git/src/smart/mod.rs
@@ -1,19 +0,0 @@
1pub mod dispatch;
2pub mod error;
3pub mod handler;
4pub mod info_refs;
5pub mod lfs;
6pub mod object;
7pub mod pktline;
8pub mod pre_receive;
9pub mod push;
10pub mod rpc;
11pub mod service;
12
13pub use dispatch::{info_refs, rpc};
14pub use error::{SmartError, SmartResult};
15pub use handler::router;
16pub use pre_receive::{PreReceiveVerdict, RefUpdate, RefVerdict};
17pub use push::{NoopPushBackend, PushCoordinator, PushOperations, RedisPushBackend};
18pub use rpc::GitService;
19pub use service::SmartService;
Deleted lib/git/src/smart/object.rs
@@ -1,59 +0,0 @@
1use std::path::{Component, Path, PathBuf};
2
3use axum::http::{StatusCode, header};
4use axum::response::{IntoResponse, Response};
5
6use crate::smart::error::{SmartError, SmartResult};
7
8pub async fn serve(bare: &Path, request_path: &str) -> SmartResult<Response> {
9 let file = resolve_within_root(bare, request_path)?;
10 match tokio::fs::read(&file).await {
11 Ok(bytes) => {
12 tracing::debug!(path = %request_path, bytes = bytes.len(), "serving dumb http object");
13 Ok((
14 StatusCode::OK,
15 [(header::CONTENT_TYPE, guess_content_type(request_path))],
16 bytes,
17 )
18 .into_response())
19 }
20 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(SmartError::NotFound(format!(
21 "file not found: {request_path}"
22 ))),
23 Err(e) => Err(SmartError::Io(e)),
24 }
25}
26
27fn resolve_within_root(root: &Path, request_path: &str) -> SmartResult<PathBuf> {
28 let mut base = root.to_path_buf();
29 for component in Path::new(request_path).components() {
30 match component {
31 Component::Normal(part) => base.push(part),
32 Component::CurDir => {}
33 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
34 tracing::warn!(path = %request_path, "dumb http path traversal denied");
35 return Err(SmartError::Forbidden("path traversal denied".into()));
36 }
37 }
38 }
39 let canonical = base
40 .canonicalize()
41 .map_err(|_| SmartError::NotFound("file not found".into()))?;
42 let root_canonical = root
43 .canonicalize()
44 .map_err(|_| SmartError::NotFound("repository not found".into()))?;
45 if canonical.starts_with(&root_canonical) {
46 Ok(canonical)
47 } else {
48 tracing::warn!(path = %request_path, "dumb http path escapes repository root");
49 Err(SmartError::Forbidden("path escapes repository root".into()))
50 }
51}
52
53fn guess_content_type(path: &str) -> &'static str {
54 if path.ends_with("/info/refs") || path.ends_with("info/refs") {
55 "text/plain; charset=utf-8"
56 } else {
57 "application/octet-stream"
58 }
59}
Deleted lib/git/src/smart/pktline.rs
@@ -1,55 +0,0 @@
1use crate::smart::error::{SmartError, SmartResult};
2
3const MAX_PKT_SIZE: usize = 65_520;
4
5pub fn encode_line(line: &[u8]) -> SmartResult<Vec<u8>> {
6 let len = line.len() + 4;
7 if len > MAX_PKT_SIZE {
8 return Err(SmartError::BadRequest(format!("pkt-line too long: {len}")));
9 }
10 let mut out = Vec::with_capacity(4 + line.len());
11 out.extend_from_slice(format!("{len:04x}").as_bytes());
12 out.extend_from_slice(line);
13 Ok(out)
14}
15
16pub fn flush_pkt() -> Vec<u8> {
17 b"0000".to_vec()
18}
19
20pub fn encode_header(service: &str) -> SmartResult<Vec<u8>> {
21 let payload = format!("# service={service}\n");
22 let mut out = encode_line(payload.as_bytes())?;
23 out.extend_from_slice(&flush_pkt());
24 Ok(out)
25}
26
27pub fn decode_stream(input: &[u8]) -> SmartResult<Vec<Vec<u8>>> {
28 let mut lines = Vec::new();
29 let mut pos = 0usize;
30 while pos + 4 <= input.len() {
31 let header = &input[pos..pos + 4];
32 let len = usize::from_str_radix(
33 std::str::from_utf8(header)
34 .map_err(|_| SmartError::BadRequest("invalid pkt-line length header".into()))?,
35 16,
36 )
37 .map_err(|_| SmartError::BadRequest("invalid pkt-line length header".into()))?;
38 pos += 4;
39 if len == 0 {
40 break;
41 }
42 if !(4..=MAX_PKT_SIZE).contains(&len) {
43 return Err(SmartError::BadRequest(format!(
44 "invalid pkt-line length: {len}"
45 )));
46 }
47 let payload_end = pos + len - 4;
48 if payload_end > input.len() {
49 return Err(SmartError::BadRequest("truncated pkt-line".into()));
50 }
51 lines.push(input[pos..payload_end].to_vec());
52 pos = payload_end;
53 }
54 Ok(lines)
55}
Deleted lib/git/src/smart/pre_receive.rs
@@ -1,311 +0,0 @@
1use globset::{Glob, GlobMatcher};
2use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
3use uuid::Uuid;
4
5use crate::share::policy::{
6 WhitelistSet, WhitelistSubject, load_branch_whitelists, load_tag_whitelists,
7};
8use crate::smart::error::{SmartError, SmartResult};
9use crate::smart::pktline;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct RefUpdate {
13 pub old_oid: String,
14 pub new_oid: String,
15 pub refname: String,
16}
17
18#[derive(Debug, Clone)]
19pub enum RefVerdict {
20 Accept,
21 Reject(String),
22}
23
24#[derive(Debug, Clone)]
25pub struct PreReceiveVerdict {
26 pub per_ref: Vec<(RefUpdate, RefVerdict)>,
27}
28
29/// Everything the per-ref judges need, loaded once per push. The two policy
30/// lists and their whitelists always travel together, so they are passed as one
31/// value instead of four parallel arguments.
32struct ProtectionRules<'a> {
33 branch_policies: &'a [models::repo::repo_branch_protection_policy::Model],
34 tag_policies: &'a [models::repo::repo_tag_protection_policy::Model],
35 branch_whitelists: &'a WhitelistSet,
36 tag_whitelists: &'a WhitelistSet,
37}
38
39/// Who is pushing and how they are privileged on this repository.
40#[derive(Debug, Clone, Copy)]
41struct PushActor {
42 user_id: i64,
43 is_admin: bool,
44}
45
46impl PreReceiveVerdict {
47 pub fn accepted_only(self) -> SmartResult<Vec<RefUpdate>> {
48 let mut accepted = Vec::with_capacity(self.per_ref.len());
49 for (r, v) in self.per_ref {
50 match v {
51 RefVerdict::Accept => accepted.push(r),
52 RefVerdict::Reject(msg) => return Err(SmartError::Forbidden(msg)),
53 }
54 }
55 Ok(accepted)
56 }
57}
58
59pub async fn validate_pre_receive(
60 db: &sea_orm::DatabaseConnection,
61 baby: &gitbaby::GitBaby,
62 repo_id: Uuid,
63 actor_user_id: i64,
64 refs: &[RefUpdate],
65) -> SmartResult<PreReceiveVerdict> {
66 tracing::debug!(
67 %repo_id,
68 %actor_user_id,
69 ref_count = refs.len(),
70 "validating pre-receive ref updates"
71 );
72 let repo = models::repo::repos::Entity::find_by_id(repo_id)
73 .one(db)
74 .await?
75 .ok_or_else(|| SmartError::NotFound(format!("repo {repo_id}")))?;
76
77 let branch_policies = models::repo::repo_branch_protection_policy::Entity::find()
78 .filter(models::repo::repo_branch_protection_policy::Column::RepoId.eq(repo.id))
79 .all(db)
80 .await?;
81 let tag_policies = models::repo::repo_tag_protection_policy::Entity::find()
82 .filter(models::repo::repo_tag_protection_policy::Column::RepoId.eq(repo.id))
83 .all(db)
84 .await?;
85
86 let actor_collab = models::repo::repo_collaborations::Entity::find()
87 .filter(models::repo::repo_collaborations::Column::RepoId.eq(repo.id))
88 .filter(models::repo::repo_collaborations::Column::UserId.eq(actor_user_id))
89 .one(db)
90 .await?;
91 let actor_is_admin = matches!(
92 actor_collab.map(|c| c.mode),
93 Some(models::repo::repo_collaborations::CollaborationMode::Admin)
94 );
95 tracing::debug!(
96 %repo_id,
97 branch_policies = branch_policies.len(),
98 tag_policies = tag_policies.len(),
99 actor_is_admin,
100 "loaded protection policies for pre-receive"
101 );
102
103 let branch_policy_ids: Vec<Uuid> = branch_policies.iter().map(|p| p.id).collect();
104 let branch_whitelists = load_branch_whitelists(db, &branch_policy_ids)
105 .await
106 .map_err(|e| SmartError::Internal(format!("load branch whitelists: {e}")))?;
107 let tag_policy_ids: Vec<Uuid> = tag_policies.iter().map(|p| p.id).collect();
108 let tag_whitelists = load_tag_whitelists(db, &tag_policy_ids)
109 .await
110 .map_err(|e| SmartError::Internal(format!("load tag whitelists: {e}")))?;
111
112 let oid_len = oid_len_of(refs);
113 let zero_oid = "0".repeat(oid_len);
114
115 let rules = ProtectionRules {
116 branch_policies: &branch_policies,
117 tag_policies: &tag_policies,
118 branch_whitelists: &branch_whitelists,
119 tag_whitelists: &tag_whitelists,
120 };
121 let actor = PushActor {
122 user_id: actor_user_id,
123 is_admin: actor_is_admin,
124 };
125
126 let mut out = Vec::with_capacity(refs.len());
127 for r in refs {
128 let verdict = judge(baby, r, &rules, actor, &zero_oid).await?;
129 match &verdict {
130 RefVerdict::Accept => {
131 tracing::debug!(refname = %r.refname, "pre-receive ref accepted");
132 }
133 RefVerdict::Reject(reason) => {
134 tracing::warn!(refname = %r.refname, %reason, "pre-receive ref rejected");
135 }
136 }
137 out.push((r.clone(), verdict));
138 }
139 Ok(PreReceiveVerdict { per_ref: out })
140}
141
142async fn judge(
143 baby: &gitbaby::GitBaby,
144 r: &RefUpdate,
145 rules: &ProtectionRules<'_>,
146 actor: PushActor,
147 zero_oid: &str,
148) -> SmartResult<RefVerdict> {
149 if let Some(branch) = r.refname.strip_prefix("refs/heads/") {
150 return judge_branch(baby, branch, r, rules, actor, zero_oid).await;
151 }
152 if let Some(tag) = r.refname.strip_prefix("refs/tags/") {
153 return Ok(judge_tag(tag, rules, actor));
154 }
155 Ok(RefVerdict::Accept)
156}
157
158async fn judge_branch(
159 baby: &gitbaby::GitBaby,
160 branch: &str,
161 r: &RefUpdate,
162 rules: &ProtectionRules<'_>,
163 actor: PushActor,
164 zero_oid: &str,
165) -> SmartResult<RefVerdict> {
166 let matchers: Vec<(
167 &models::repo::repo_branch_protection_policy::Model,
168 GlobMatcher,
169 )> = match rules
170 .branch_policies
171 .iter()
172 .filter_map(|m| {
173 Glob::new(&m.branch_pattern)
174 .ok()
175 .map(|g| (m, g.compile_matcher()))
176 })
177 .find(|(_, matcher)| matcher.is_match(branch))
178 {
179 Some(found) => vec![found],
180 None => Vec::new(),
181 };
182 if matchers.is_empty() {
183 return Ok(RefVerdict::Accept);
184 }
185 let policy = matchers[0].0;
186 let is_delete = r.new_oid == zero_oid;
187 let is_force = r.old_oid != zero_oid
188 && r.new_oid != zero_oid
189 && !is_fast_forward(baby, &r.old_oid, &r.new_oid).await?;
190
191 if is_delete {
192 return Ok(RefVerdict::Reject(format!(
193 "deleting protected branch `{branch}` is not allowed"
194 )));
195 }
196
197 if !policy.enable_push {
198 if policy.enable_push_whitelist
199 && rules
200 .branch_whitelists
201 .push_actor_allowed(WhitelistSubject::User(actor.user_id))
202 {
203 return Ok(RefVerdict::Accept);
204 }
205 return Ok(RefVerdict::Reject(format!(
206 "pushes to protected branch `{branch}` are not allowed"
207 )));
208 }
209
210 if is_force && !policy.enable_force_push && !actor.is_admin {
211 if rules
212 .branch_whitelists
213 .push_actor_allowed(WhitelistSubject::User(actor.user_id))
214 {
215 return Ok(RefVerdict::Accept);
216 }
217 return Ok(RefVerdict::Reject(format!(
218 "force-push to protected branch `{branch}` is not allowed"
219 )));
220 }
221
222 Ok(RefVerdict::Accept)
223}
224
225fn judge_tag(tag: &str, rules: &ProtectionRules<'_>, actor: PushActor) -> RefVerdict {
226 for p in rules.tag_policies {
227 let Ok(glob) = Glob::new(&p.name_pattern) else {
228 continue;
229 };
230 if !glob.compile_matcher().is_match(tag) {
231 continue;
232 }
233 if actor.is_admin || rules.tag_whitelists.tag_modifier_allowed(actor.user_id) {
234 return RefVerdict::Accept;
235 }
236 return RefVerdict::Reject(format!(
237 "tag `{tag}` is protected; only the policy owner(s) can modify it"
238 ));
239 }
240 RefVerdict::Accept
241}
242
243/// Real fast-forward check via `git merge-base --is-ancestor old new`.
244/// `old == new` (no-op push) and `old == zero_oid` (new branch) are not
245/// ancestors checks — they short-circuit to true without spawning git.
246async fn is_fast_forward(baby: &gitbaby::GitBaby, old: &str, new: &str) -> SmartResult<bool> {
247 if old == new {
248 return Ok(true);
249 }
250 if old.chars().all(|c| c == '0') || new.chars().all(|c| c == '0') {
251 return Ok(false);
252 }
253 baby.is_ancestor(old, new)
254 .await
255 .map_err(|e| SmartError::Internal(format!("is_ancestor failed: {e}")))
256}
257
258fn oid_len_of(refs: &[RefUpdate]) -> usize {
259 refs.iter()
260 .find_map(|r| {
261 let max = r.old_oid.len().max(r.new_oid.len());
262 (max == 40 || max == 64).then_some(max)
263 })
264 .unwrap_or(40)
265}
266
267pub fn parse_receive_pack_refs(input: &[u8]) -> SmartResult<Vec<RefUpdate>> {
268 let lines = pktline::decode_stream(input)
269 .map_err(|e| SmartError::BadRequest(format!("invalid receive-pack request: {e}")))?;
270 let mut out = Vec::new();
271 for mut line in lines {
272 if let Some(nul) = line.iter().position(|b| *b == 0) {
273 line.truncate(nul);
274 }
275 if line.is_empty() {
276 continue;
277 }
278 let s = std::str::from_utf8(&line).map_err(|_| {
279 SmartError::BadRequest("receive-pack ref line is not valid UTF-8".to_string())
280 })?;
281 let mut parts = s.splitn(3, ' ');
282 let old = parts.next().unwrap_or_default();
283 let new = parts.next().unwrap_or_default();
284 let refname = parts.next().unwrap_or_default();
285 if old.is_empty() || new.is_empty() || refname.is_empty() {
286 return Err(SmartError::BadRequest(format!(
287 "malformed receive-pack ref line: {s}"
288 )));
289 }
290 let oid_len = old.len();
291 if oid_len != 40 && oid_len != 64 {
292 return Err(SmartError::BadRequest(format!(
293 "invalid oid length {oid_len} in receive-pack ref line: {s}"
294 )));
295 }
296 if new.len() != oid_len
297 || !old.chars().all(|c| c.is_ascii_hexdigit())
298 || !new.chars().all(|c| c.is_ascii_hexdigit())
299 {
300 return Err(SmartError::BadRequest(format!(
301 "invalid oid in receive-pack ref line: {s}"
302 )));
303 }
304 out.push(RefUpdate {
305 old_oid: old.to_string(),
306 new_oid: new.to_string(),
307 refname: refname.to_string(),
308 });
309 }
310 Ok(out)
311}
Deleted lib/git/src/smart/push.rs
@@ -1,270 +0,0 @@
1use std::sync::Arc;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use export::{AppRedis, RedisConnection};
6use uuid::Uuid;
7
8use crate::smart::error::{SmartError, SmartResult};
9
10#[async_trait]
11pub trait PushOperations: Send + Sync {
12 async fn acquire(&self, key: &str, member: &[u8], ttl_secs: u64) -> SmartResult<bool>;
13 async fn renew(&self, key: &str, token: &[u8], ttl_secs: u64) -> SmartResult<bool>;
14 async fn release(&self, key: &str, token: &[u8]) -> SmartResult<()>;
15 async fn enqueue(&self, key: &str, member: &[u8]) -> SmartResult<()>;
16 async fn dequeue(&self, key: &str, member: &[u8]) -> SmartResult<()>;
17}
18
19macro_rules! dispatch {
20 ($conn:expr, |$c:ident| $body:expr) => {
21 match &mut $conn {
22 RedisConnection::Standalone($c) => $body,
23 RedisConnection::Cluster($c) => $body,
24 }
25 };
26}
27
28const RENEW_SCRIPT: &str = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2]) else return 0 end";
29const RELEASE_SCRIPT: &str = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end";
30
31pub struct RedisPushBackend {
32 pub kv: Arc<AppRedis>,
33}
34
35impl RedisPushBackend {
36 async fn eval_bool(&self, script: &str, key: &str, argv: Vec<Vec<u8>>) -> SmartResult<bool> {
37 let mut conn = self.kv.connection_clone();
38 let mut cmd = redis::cmd("EVAL");
39 cmd.arg(script).arg(1).arg(key);
40 for a in argv {
41 cmd.arg(a);
42 }
43 let n: i64 = dispatch!(conn, |c| cmd.query_async(c).await)?;
44 Ok(n != 0)
45 }
46}
47
48#[async_trait]
49impl PushOperations for RedisPushBackend {
50 async fn acquire(&self, key: &str, member: &[u8], ttl_secs: u64) -> SmartResult<bool> {
51 let mut conn = self.kv.connection_clone();
52 let resp: Option<String> = dispatch!(conn, |c| {
53 redis::cmd("SET")
54 .arg(key)
55 .arg(member)
56 .arg("NX")
57 .arg("EX")
58 .arg(ttl_secs)
59 .query_async(c)
60 .await
61 })?;
62 Ok(resp.is_some())
63 }
64
65 async fn renew(&self, key: &str, token: &[u8], ttl_secs: u64) -> SmartResult<bool> {
66 self.eval_bool(
67 RENEW_SCRIPT,
68 key,
69 vec![token.to_vec(), ttl_secs.to_string().into_bytes()],
70 )
71 .await
72 }
73
74 async fn release(&self, key: &str, token: &[u8]) -> SmartResult<()> {
75 self.eval_bool(RELEASE_SCRIPT, key, vec![token.to_vec()])
76 .await?;
77 Ok(())
78 }
79
80 async fn enqueue(&self, key: &str, member: &[u8]) -> SmartResult<()> {
81 let mut conn = self.kv.connection_clone();
82 let _: i64 = dispatch!(conn, |c| {
83 redis::cmd("RPUSH")
84 .arg(key)
85 .arg(member)
86 .query_async(c)
87 .await
88 })?;
89 Ok(())
90 }
91
92 async fn dequeue(&self, key: &str, member: &[u8]) -> SmartResult<()> {
93 let mut conn = self.kv.connection_clone();
94 let _: i64 = dispatch!(conn, |c| {
95 redis::cmd("LREM")
96 .arg(key)
97 .arg(1)
98 .arg(member)
99 .query_async(c)
100 .await
101 })?;
102 Ok(())
103 }
104}
105
106pub struct NoopPushBackend;
107
108const NOPUSH_BACKEND_ERROR: &str =
109 "push requires redis: SmartService kv is not configured (NoopPushBackend)";
110
111#[async_trait]
112impl PushOperations for NoopPushBackend {
113 async fn acquire(&self, _key: &str, _member: &[u8], _ttl_secs: u64) -> SmartResult<bool> {
114 Err(SmartError::Internal(NOPUSH_BACKEND_ERROR.to_string()))
115 }
116
117 async fn renew(&self, _key: &str, _token: &[u8], _ttl_secs: u64) -> SmartResult<bool> {
118 Err(SmartError::Internal(NOPUSH_BACKEND_ERROR.to_string()))
119 }
120
121 async fn release(&self, _key: &str, _token: &[u8]) -> SmartResult<()> {
122 Ok(())
123 }
124
125 async fn enqueue(&self, _key: &str, _member: &[u8]) -> SmartResult<()> {
126 Err(SmartError::Internal(NOPUSH_BACKEND_ERROR.to_string()))
127 }
128
129 async fn dequeue(&self, _key: &str, _member: &[u8]) -> SmartResult<()> {
130 Err(SmartError::Internal(NOPUSH_BACKEND_ERROR.to_string()))
131 }
132}
133
134#[derive(Clone)]
135pub struct PushCoordinator {
136 backend: Arc<dyn PushOperations>,
137 lock_ttl_secs: u64,
138 renew_interval_secs: u64,
139 poll_interval: Duration,
140}
141
142const DEFAULT_LOCK_TTL_SECS: u64 = 120;
143const DEFAULT_RENEW_INTERVAL_SECS: u64 = 40;
144const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(200);
145
146impl PushCoordinator {
147 pub fn new(backend: Arc<dyn PushOperations>) -> Self {
148 Self {
149 backend,
150 lock_ttl_secs: DEFAULT_LOCK_TTL_SECS,
151 renew_interval_secs: DEFAULT_RENEW_INTERVAL_SECS,
152 poll_interval: DEFAULT_POLL_INTERVAL,
153 }
154 }
155
156 pub async fn run<T>(
157 &self,
158 key: &str,
159 f: impl std::future::Future<Output = SmartResult<T>>,
160 ) -> SmartResult<T> {
161 let token = Uuid::now_v7().to_string();
162 let result: SmartResult<T> = async {
163 if self
164 .backend
165 .acquire(key, token.as_bytes(), self.lock_ttl_secs)
166 .await?
167 {
168 tracing::debug!(key, ttl_secs = self.lock_ttl_secs, "push lock acquired");
169 return self.run_locked(key, &token, f).await;
170 }
171
172 tracing::debug!(key, "push lock busy; queued, waiting for lock");
173 self.backend.enqueue(key, token.as_bytes()).await?;
174 loop {
175 tokio::time::sleep(self.poll_interval).await;
176 if self
177 .backend
178 .acquire(key, token.as_bytes(), self.lock_ttl_secs)
179 .await?
180 {
181 self.backend.dequeue(key, token.as_bytes()).await?;
182 tracing::debug!(key, "push lock acquired after waiting");
183 return self.run_locked(key, &token, f).await;
184 }
185 }
186 }
187 .await;
188 result
189 }
190
191 async fn run_locked<T>(
192 &self,
193 key: &str,
194 token: &str,
195 f: impl std::future::Future<Output = SmartResult<T>>,
196 ) -> SmartResult<T> {
197 let heartbeat = Heartbeat::spawn(
198 self.backend.clone(),
199 key.to_string(),
200 token.to_string(),
201 self.lock_ttl_secs,
202 self.renew_interval_secs,
203 );
204 let result = f.await;
205 heartbeat.stop().await;
206 self.backend.release(key, token.as_bytes()).await?;
207 tracing::debug!(key, ok = result.is_ok(), "push lock released");
208 result
209 }
210}
211
212struct Heartbeat {
213 handle: Option<tokio::task::JoinHandle<()>>,
214 stop: Arc<tokio::sync::Notify>,
215}
216
217impl Drop for Heartbeat {
218 fn drop(&mut self) {
219 self.stop.notify_one();
220 if let Some(handle) = &self.handle
221 && !handle.is_finished()
222 {
223 handle.abort();
224 }
225 }
226}
227
228impl Heartbeat {
229 fn spawn(
230 backend: Arc<dyn PushOperations>,
231 key: String,
232 token: String,
233 ttl: u64,
234 interval: u64,
235 ) -> Self {
236 let stop = Arc::new(tokio::sync::Notify::new());
237 let stop_clone = stop.clone();
238 let handle = tokio::spawn(async move {
239 loop {
240 tokio::select! {
241 _ = stop_clone.notified() => break,
242 _ = tokio::time::sleep(Duration::from_secs(interval)) => {
243 match backend.renew(&key, token.as_bytes(), ttl).await {
244 Ok(false) => {
245 tracing::debug!(key = %key, "push lock no longer held; stopping renewer");
246 break;
247 }
248 Err(error) => {
249 tracing::warn!(%error, "lock renew failed, stopping renewer");
250 break;
251 }
252 Ok(true) => {}
253 }
254 }
255 }
256 }
257 });
258 Self {
259 handle: Some(handle),
260 stop,
261 }
262 }
263
264 async fn stop(mut self) {
265 self.stop.notify_one();
266 if let Some(handle) = self.handle.take() {
267 let _ = handle.await;
268 }
269 }
270}
Deleted lib/git/src/smart/rpc.rs
@@ -1,250 +0,0 @@
1use std::path::Path;
2use std::process::Stdio;
3use std::sync::Arc;
4use std::time::Duration;
5
6use bytes::Bytes;
7use futures::StreamExt;
8use tokio::io::{AsyncReadExt, AsyncWriteExt};
9use tokio::process::Command;
10use tokio::sync::{Mutex, oneshot};
11use tokio_util::io::ReaderStream;
12
13use crate::smart::error::{SmartError, SmartResult};
14
15pub type RpcStream = futures::stream::BoxStream<'static, Result<Bytes, std::io::Error>>;
16
17const MAX_STDERR_BYTES: usize = 64 * 1024 * 1024;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum GitService {
21 UploadPack,
22 ReceivePack,
23}
24
25impl GitService {
26 pub fn from_name(name: &str) -> SmartResult<Self> {
27 match name {
28 "git-upload-pack" => Ok(Self::UploadPack),
29 "git-receive-pack" => Ok(Self::ReceivePack),
30 _ => Err(SmartError::BadRequest(format!(
31 "unsupported git service: {name}"
32 ))),
33 }
34 }
35
36 pub fn name(self) -> &'static str {
37 match self {
38 Self::UploadPack => "upload-pack",
39 Self::ReceivePack => "receive-pack",
40 }
41 }
42
43 pub fn dashed_name(self) -> &'static str {
44 match self {
45 Self::UploadPack => "git-upload-pack",
46 Self::ReceivePack => "git-receive-pack",
47 }
48 }
49
50 pub fn advertisement_content_type(self) -> &'static str {
51 match self {
52 Self::UploadPack => "application/x-git-upload-pack-advertisement",
53 Self::ReceivePack => "application/x-git-receive-pack-advertisement",
54 }
55 }
56
57 pub fn result_content_type(self) -> &'static str {
58 match self {
59 Self::UploadPack => "application/x-git-upload-pack-result",
60 Self::ReceivePack => "application/x-git-receive-pack-result",
61 }
62 }
63}
64
65fn build_command(bare_path: &Path, service: GitService, advertise: bool) -> Command {
66 let mut cmd = Command::new("git");
67 cmd.arg(service.name()).arg("--stateless-rpc");
68 if advertise {
69 cmd.arg("--advertise-refs");
70 }
71 cmd.arg(bare_path)
72 .env("GIT_DIR", bare_path)
73 .env("GIT_PROTOCOL", "version=2")
74 .env("GIT_TERMINAL_PROMPT", "0")
75 .env("GIT_CONFIG_NOSYSTEM", "1")
76 .env("GIT_NO_REPLACE_OBJECTS", "1")
77 .stdin(Stdio::piped())
78 .stdout(Stdio::piped())
79 .stderr(Stdio::piped());
80 cmd
81}
82
83#[allow(clippy::type_complexity)]
84fn spawn_with_supervisor(
85 bare_path: &Path,
86 service: GitService,
87 advertise: bool,
88 input: Bytes,
89 timeout_secs: u64,
90) -> SmartResult<(
91 RpcStream,
92 Option<tokio::sync::oneshot::Receiver<SmartResult<()>>>,
93)> {
94 let mut cmd = build_command(bare_path, service, advertise);
95 let mut child = cmd.spawn().map_err(|e| {
96 SmartError::Internal(format!("failed to spawn git {}: {e}", service.name()))
97 })?;
98 tracing::debug!(
99 svc = service.name(),
100 advertise,
101 bare = %bare_path.display(),
102 input_bytes = input.len(),
103 timeout_secs,
104 pid = ?child.id(),
105 "spawned git rpc process"
106 );
107 let mut stdin = child
108 .stdin
109 .take()
110 .ok_or_else(|| SmartError::Internal("stdin not piped".to_string()))?;
111 let stdout = child
112 .stdout
113 .take()
114 .ok_or_else(|| SmartError::Internal("stdout not piped".to_string()))?;
115 let mut stderr = child
116 .stderr
117 .take()
118 .ok_or_else(|| SmartError::Internal("stderr not piped".to_string()))?;
119
120 let child_arc = Arc::new(Mutex::new(Some(child)));
121
122 {
123 let child_arc = child_arc.clone();
124 tokio::spawn(async move {
125 if stdin.write_all(&input).await.is_err() {
126 tracing::warn!(
127 svc = service.name(),
128 "writing rpc input to git stdin failed; killing process"
129 );
130 if let Some(mut ch) = child_arc.lock().await.take() {
131 let _ = ch.start_kill();
132 let _ = ch.wait().await;
133 }
134 }
135 let _ = stdin.shutdown().await;
136 });
137 }
138
139 let (tx, rx) = oneshot::channel();
140 {
141 let child_arc = child_arc.clone();
142 tokio::spawn(async move {
143 let status = {
144 let mut guard = child_arc.lock().await;
145 match guard.as_mut() {
146 None => None,
147 Some(ch) => {
148 match tokio::time::timeout(Duration::from_secs(timeout_secs), ch.wait())
149 .await
150 {
151 Ok(Ok(s)) => Some(s),
152 _ => {
153 tracing::warn!(
154 svc = service.name(),
155 timeout_secs,
156 "git rpc process timed out; killing"
157 );
158 let _ = ch.start_kill();
159 let _ = ch.wait().await;
160 None
161 }
162 }
163 }
164 }
165 };
166 let mut buf = vec![0u8; 8192];
167 let mut total = 0usize;
168 let mut text = Vec::new();
169 loop {
170 match stderr.read(&mut buf).await {
171 Ok(0) | Err(_) => break,
172 Ok(n) => {
173 total += n;
174 if total <= MAX_STDERR_BYTES {
175 text.extend_from_slice(&buf[..n]);
176 }
177 }
178 }
179 }
180 let verdict = match status {
181 Some(s) if s.success() => {
182 tracing::debug!(
183 svc = service.name(),
184 stderr_bytes = total,
185 "git rpc process exited successfully"
186 );
187 Ok(())
188 }
189 Some(s) => {
190 tracing::warn!(
191 svc = service.name(),
192 exit = %s,
193 stderr = %String::from_utf8_lossy(text.as_slice()).trim(),
194 "git rpc process exited with error"
195 );
196 Err(SmartError::Internal(format!(
197 "git {} exited with {s}: {}",
198 service.name(),
199 String::from_utf8_lossy(text.as_slice()).trim()
200 )))
201 }
202 None => Err(SmartError::Internal(format!(
203 "git {} timed out after {timeout_secs}s",
204 service.name()
205 ))),
206 };
207 let _ = tx.send(verdict);
208 });
209 }
210
211 let stream: RpcStream = Box::pin(ReaderStream::new(stdout));
212 Ok((stream, Some(rx)))
213}
214
215pub async fn run_rpc_stream(
216 bare_path: &Path,
217 service: GitService,
218 advertise: bool,
219 input: Bytes,
220 timeout_secs: u64,
221) -> SmartResult<RpcStream> {
222 let (stream, _rx) = spawn_with_supervisor(bare_path, service, advertise, input, timeout_secs)?;
223 Ok(stream)
224}
225
226pub async fn run_rpc_bytes(
227 bare_path: &Path,
228 service: GitService,
229 advertise: bool,
230 input: Bytes,
231 timeout_secs: u64,
232) -> SmartResult<Bytes> {
233 let (mut stream, rx) =
234 spawn_with_supervisor(bare_path, service, advertise, input, timeout_secs)?;
235 let mut out: Vec<u8> = Vec::new();
236 while let Some(chunk) = stream.next().await {
237 out.extend_from_slice(&chunk.map_err(|e| {
238 SmartError::Internal(format!("git {} stdout read failed: {e}", service.name()))
239 })?);
240 }
241 if let Some(rx) = rx {
242 let _ = rx.await;
243 }
244 tracing::debug!(
245 svc = service.name(),
246 output_bytes = out.len(),
247 "git rpc output collected"
248 );
249 Ok(Bytes::from(out))
250}
Deleted lib/git/src/smart/service.rs
@@ -1,103 +0,0 @@
1use std::sync::Arc;
2
3use export::AppRedis;
4use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
5use uuid::Uuid;
6
7use crate::context::facade::GitContext;
8use crate::service::GitService;
9use crate::smart::error::{SmartError, SmartResult};
10use crate::smart::push::{NoopPushBackend, PushCoordinator, PushOperations, RedisPushBackend};
11use crate::sync::queue::SyncQueue;
12use config::git::SyncSettings;
13
14#[derive(Clone)]
15pub struct SmartService {
16 pub git: GitService,
17 pub kv: Option<Arc<AppRedis>>,
18 pub queue: Option<Arc<SyncQueue>>,
19 pub sync_settings: SyncSettings,
20 /// When set, pushes also publish a CI event on this Redis list topic.
21 pub ci_event_topic: Option<String>,
22}
23
24impl SmartService {
25 pub fn new(git: GitService) -> Self {
26 Self {
27 git,
28 kv: None,
29 queue: None,
30 sync_settings: SyncSettings::default(),
31 ci_event_topic: None,
32 }
33 }
34
35 pub fn with_ci_event_topic(mut self, topic: Option<String>) -> Self {
36 self.ci_event_topic = topic;
37 self
38 }
39
40 pub fn ci_event_topic(&self) -> Option<&str> {
41 self.ci_event_topic.as_deref()
42 }
43
44 pub fn with_kv(mut self, kv: Option<Arc<AppRedis>>) -> Self {
45 self.kv = kv;
46 self
47 }
48
49 pub fn with_queue(mut self, queue: Option<Arc<SyncQueue>>) -> Self {
50 self.queue = queue;
51 self
52 }
53
54 pub fn with_sync_settings(mut self, settings: SyncSettings) -> Self {
55 self.sync_settings = settings;
56 self
57 }
58
59 pub fn push_coordinator(&self) -> PushCoordinator {
60 let backend: Arc<dyn PushOperations> = match &self.kv {
61 Some(kv) => Arc::new(RedisPushBackend { kv: kv.clone() }),
62 None => Arc::new(NoopPushBackend),
63 };
64 PushCoordinator::new(backend)
65 }
66
67 pub fn bare_path(&self, ctx: &GitContext) -> std::path::PathBuf {
68 ctx.repo_dir()
69 }
70
71 pub async fn resolve(&self, space: &str, repo: &str) -> SmartResult<GitContext> {
72 self.git
73 .context(space.to_string(), repo.to_string())
74 .await
75 .map_err(|e| match e {
76 crate::share::errors::GitError::NotFound => {
77 SmartError::NotFound(format!("repo {space}/{repo}"))
78 }
79 other => SmartError::from(other),
80 })
81 }
82
83 pub async fn owning_space_id(&self, ctx: &GitContext) -> SmartResult<Uuid> {
84 let space_name = self.space_for(ctx).await?;
85 let Some(space) = models::space::spaces::Entity::find()
86 .filter(models::space::spaces::Column::LowerName.eq(space_name.to_lowercase()))
87 .one(self.git.db_read())
88 .await?
89 else {
90 return Err(SmartError::NotFound(format!("space {space_name}")));
91 };
92 Ok(space.id)
93 }
94
95 async fn space_for(&self, ctx: &GitContext) -> SmartResult<String> {
96 let space = crate::share::access::space_for_dir(self.git.db_read(), ctx.model.dir_id)
97 .await?
98 .ok_or_else(|| SmartError::NotFound("space".into()))?;
99 Ok(space.lower_name)
100 }
101}
102
103pub use crate::smart::error::SmartError as SmartServiceError;
Deleted lib/git/src/ssh/handler/auth.rs
@@ -1,115 +0,0 @@
1use std::str::FromStr;
2
3use models::users::user;
4use russh::keys::PublicKey;
5use russh::keys::ssh_key::HashAlg;
6use russh::server::Auth;
7use sea_orm::EntityTrait;
8
9use crate::share::errors::GitError;
10use crate::ssh::handler::GitSshHandler;
11
12impl GitSshHandler {
13 /// Resolve the account owning `public_key` through KenOAuth. The SSH
14 /// username is ignored: GitData virtual accounts (`git@host`) identify
15 /// users by key, and KenOAuth owns the key → user mapping.
16 async fn find_user_by_public_key(
17 &self,
18 public_key: &PublicKey,
19 ) -> Result<Option<user::Model>, GitError> {
20 let git = &self.service.smart.git;
21 let Some(client) = git.state.kenauth.as_ref() else {
22 return Err(GitError::Internal(
23 "kenoauth is not configured for ssh authentication".into(),
24 ));
25 };
26 let principal = match client.lookup_ssh_key(&public_key.to_string()).await {
27 Ok(Some(principal)) => principal,
28 Ok(None) => return Ok(None),
29 Err(error) => {
30 tracing::warn!(%error, "ssh key lookup failed");
31 return Ok(None);
32 }
33 };
34 let user = user::Entity::find_by_id(principal.user_id)
35 .one(git.db_read())
36 .await?;
37 Ok(user.filter(|row| row.is_active))
38 }
39
40 pub async fn auth_publickey_offered_impl(
41 &mut self,
42 user: &str,
43 public_key: &PublicKey,
44 ) -> Result<Auth, russh::Error> {
45 let fingerprint = public_key.fingerprint(HashAlg::Sha256).to_string();
46 if self.over_capacity {
47 tracing::debug!(user, %fingerprint, "publickey offer rejected: server over capacity");
48 return Ok(Auth::reject());
49 }
50 if user.is_empty() || public_key.to_string().len() < 32 {
51 tracing::debug!(user, %fingerprint, "publickey offer rejected: malformed request");
52 return Ok(Auth::reject());
53 }
54 match self.find_user_by_public_key(public_key).await {
55 Ok(Some(_)) => {
56 tracing::debug!(user, %fingerprint, "publickey offer matched a known key");
57 Ok(Auth::Accept)
58 }
59 Ok(None) => {
60 tracing::info!(user, %fingerprint, client = ?self.client_addr, "publickey offer rejected: key not registered");
61 Ok(Auth::reject())
62 }
63 Err(error) => {
64 tracing::warn!(user, %fingerprint, "ssh auth lookup failed: {error}");
65 Err(russh::Error::NotAuthenticated)
66 }
67 }
68 }
69
70 pub async fn auth_publickey_impl(
71 &mut self,
72 user: &str,
73 public_key: &PublicKey,
74 ) -> Result<Auth, russh::Error> {
75 let fingerprint = public_key.fingerprint(HashAlg::Sha256).to_string();
76 if self.over_capacity {
77 tracing::debug!(user, %fingerprint, "publickey auth rejected: server over capacity");
78 return Ok(Auth::reject());
79 }
80 if user.is_empty() || public_key.to_string().len() < 32 {
81 tracing::debug!(user, %fingerprint, "publickey auth rejected: malformed request");
82 return Ok(Auth::reject());
83 }
84 match self.find_user_by_public_key(public_key).await {
85 Ok(Some(found)) => {
86 if found.is_restricted || found.prohibit_login {
87 tracing::warn!(
88 user,
89 user_id = %found.uid,
90 %fingerprint,
91 "publickey auth rejected: user restricted or login prohibited"
92 );
93 return Ok(Auth::reject());
94 }
95 tracing::info!(
96 user,
97 user_id = %found.uid,
98 %fingerprint,
99 client = ?self.client_addr,
100 "ssh publickey auth accepted"
101 );
102 self.operator = Some(found);
103 Ok(Auth::Accept)
104 }
105 Ok(None) => {
106 tracing::info!(user, %fingerprint, client = ?self.client_addr, "publickey auth rejected: key not registered");
107 Ok(Auth::reject())
108 }
109 Err(error) => {
110 tracing::warn!(user, %fingerprint, "ssh auth lookup failed: {error}");
111 Err(russh::Error::NotAuthenticated)
112 }
113 }
114 }
115}
No newline at end of file
Deleted lib/git/src/ssh/handler/channel.rs
@@ -1,746 +0,0 @@
1use std::sync::Arc;
2
3use bytes::Bytes;
4use cache::CacheClient;
5use russh::ChannelId;
6use russh::server::Session;
7use sea_orm::EntityTrait;
8use tokio::io::{AsyncReadExt, AsyncWriteExt};
9
10use crate::context::facade::GitContext;
11use crate::smart::error::SmartError;
12use crate::smart::pre_receive::parse_receive_pack_refs;
13use crate::ssh::error::SshError;
14use crate::ssh::git_service::apply_hardened_env;
15use crate::ssh::handler::ChannelState;
16use crate::ssh::handler::GitSshHandler;
17use crate::ssh::handler::git_op::GitOperation;
18use crate::sync::post_receive::enqueue_post_receive_push_with_ci_logged;
19
20pub const MAX_SSH_CHANNEL_BUF: usize = 64 * 1024 * 1024;
21pub const MAX_GIT_OUTPUT_BUF: usize = 256 * 1024 * 1024;
22
23fn hex_preview(buf: &[u8]) -> String {
24 let n = buf.len().min(96);
25 let mut s = String::with_capacity(n * 2);
26 for b in &buf[..n] {
27 s.push_str(&format!("{b:02x}"));
28 }
29 s
30}
31
32impl GitSshHandler {
33 pub async fn data_impl(
34 &mut self,
35 channel: ChannelId,
36 data: &[u8],
37 session: &mut Session,
38 ) -> Result<(), russh::Error> {
39 let state = self.channels.entry(channel).or_default();
40 if state.buffer_overflow {
41 return Ok(());
42 }
43 if state.dispatched {
44 if let Some(tx) = &state.stdin_tx {
45 let _ = tx.send(data.to_vec());
46 }
47 return Ok(());
48 }
49 let projected = state.buffer.len().saturating_add(data.len());
50 if projected > MAX_SSH_CHANNEL_BUF {
51 tracing::warn!(
52 channel = ?channel,
53 buffered = state.buffer.len(),
54 incoming = data.len(),
55 limit = MAX_SSH_CHANNEL_BUF,
56 "ssh channel buffer overflow; closing channel"
57 );
58 state.buffer_overflow = true;
59 state.buffer.clear();
60 let _ = session.close(channel);
61 let _ = session.flush();
62 return Ok(());
63 }
64 state.buffer.extend_from_slice(data);
65 if state.git_op.is_some() && state.buffer.len() == data.len() {
66 tracing::debug!(
67 ?channel,
68 first_hex = %hex_preview(&state.buffer),
69 "ssh: first data chunk on armed channel"
70 );
71 }
72 // Push-only: receive-pack runs after EOF, so its negotiation reply has
73 // to be synthesized here. Upload services must not receive it, their
74 // own process emits the real NAK/ACK.
75 let is_receive = matches!(state.git_op, Some(GitOperation::ReceivePack { .. }));
76 if is_receive && !state.nak_sent && Self::wants_section_complete(&state.buffer) {
77 // Over SSH the request and response share one stream: send-pack
78 // stops after the wants section and waits for NAK before it sends
79 // the pack. We only spawn receive-pack after channel EOF, so
80 // synthesize the negotiation reply now; the real process' own NAK
81 // is stripped from stdout later (see run_git).
82 tracing::debug!(?channel, "ssh push: synthesized NAK for wants section");
83 state.nak_sent = true;
84 let _ = session.data(channel, Bytes::from_static(b"0008NAK\n0000"));
85 let _ = session.flush();
86 }
87 Ok(())
88 }
89
90 /// True when the buffer head is a complete `want` negotiation section
91 /// terminated by a flush packet.
92 fn wants_section_complete(buf: &[u8]) -> bool {
93 let mut i = 0usize;
94 let mut saw_want = false;
95 while i + 4 <= buf.len() {
96 let Ok(len) =
97 usize::from_str_radix(std::str::from_utf8(&buf[i..i + 4]).unwrap_or_default(), 16)
98 else {
99 return false;
100 };
101 if len == 0 {
102 return saw_want;
103 }
104 if len < 4 || i + len > buf.len() {
105 return false;
106 }
107 if !buf[i + 4..i + len].starts_with(b"want ") {
108 return false;
109 }
110 saw_want = true;
111 i += len;
112 }
113 false
114 }
115
116 pub async fn channel_close_impl(&mut self, channel: ChannelId) {
117 tracing::debug!(?channel, "ssh channel closed");
118 if let Some(state) = self.channels.remove(&channel)
119 && let Some(abort) = state.stream_abort
120 {
121 abort.abort();
122 }
123 }
124
125 pub async fn channel_eof_impl(
126 &mut self,
127 channel: ChannelId,
128 session: &mut Session,
129 ) -> Result<(), russh::Error> {
130 let Some(state) = self.channels.get_mut(&channel) else {
131 let _ = session.close(channel);
132 let _ = session.flush();
133 return Ok(());
134 };
135 if state.dispatched {
136 // Half-close only: the git process may still have data to write
137 // back (upload-pack answers with the packfile after `done`), so the
138 // session is torn down when the pump task finishes, not here.
139 if let Some(tx) = state.stdin_tx.take() {
140 drop(tx);
141 }
142 tracing::debug!(?channel, "ssh: client half-closed duplex channel");
143 return Ok(());
144 }
145 let state = self.channels.remove(&channel).expect("channel state");
146 self.dispatch_state(channel, state, session.handle(), "eof");
147 Ok(())
148 }
149
150 /// Detach buffered channel state and run its git operation in the
151 /// background. `reason` is only used for tracing.
152 fn dispatch_state(
153 &mut self,
154 channel: ChannelId,
155 state: ChannelState,
156 handle: russh::server::Handle,
157 reason: &str,
158 ) {
159 let mut state = state;
160 let overflowed = state.buffer_overflow;
161 let nak_sent = state.nak_sent;
162 let op = state.git_op.take();
163 let buffer = std::mem::take(&mut state.buffer);
164 let repo_id = state.repo_id;
165 let space_id = state.space_id;
166 let actor_id = state.actor_id;
167 tracing::debug!(
168 ?channel,
169 op = op.as_ref().map(|o| o.service_arg()),
170 buffered_bytes = buffer.len(),
171 repo_id = ?repo_id,
172 actor_id = ?actor_id,
173 overflowed,
174 %reason,
175 "ssh dispatching git operation"
176 );
177 let push_coordinator = self.service.push_coordinator();
178 let queue = self.service.smart.queue.clone();
179 let sync_settings = self.service.sync_settings.clone();
180 let ci_topic = self.service.smart.ci_event_topic().map(|t| t.to_string());
181 let ssh_service = self.service.clone();
182
183 let db = self.service.smart.git.state.database.clone();
184 let rpc_timeout = self.service.smart.git.state.config.git.git_rpc_timeout_secs;
185 tokio::spawn(async move {
186 if overflowed {
187 _ = handle.exit_status_request(channel, 1);
188 _ = handle.eof(channel);
189 _ = handle.close(channel);
190 let _ = handle
191 .data(
192 channel,
193 Bytes::from_static(
194 b"remote: request rejected: ssh channel buffer cap exceeded\n",
195 ),
196 )
197 .await;
198 return;
199 }
200 let is_write = op.as_ref().is_some_and(|o| o.is_write());
201 let is_receive = matches!(
202 op.as_ref(),
203 Some(crate::ssh::handler::git_op::GitOperation::ReceivePack { .. })
204 );
205
206 if is_receive && let (Some(repo_id), Some(actor_uid)) = (repo_id, actor_id) {
207 match parse_receive_pack_refs(&buffer) {
208 Ok(updates) if !updates.is_empty() => {
209 let baby = match build_baby_for_pre_receive(&ssh_service, repo_id).await {
210 Some(b) => b,
211 None => {
212 tracing::error!(
213 repo_id = %repo_id,
214 "cannot build baby for pre-receive; refusing push"
215 );
216 let _ = handle
217 .data(
218 channel,
219 Bytes::from_static(
220 b"remote: internal error during pre-receive setup\n",
221 ),
222 )
223 .await;
224 _ = handle.exit_status_request(channel, 1);
225 _ = handle.eof(channel);
226 _ = handle.close(channel);
227 return;
228 }
229 };
230 let verdict = match crate::smart::pre_receive::validate_pre_receive(
231 db.read(),
232 &baby,
233 repo_id,
234 actor_uid,
235 &updates,
236 )
237 .await
238 {
239 Ok(v) => v,
240 Err(e) => {
241 tracing::error!(
242 ?e, repo_id = %repo_id,
243 "pre-receive validation failed; refusing push"
244 );
245 let _ = handle
246 .data(
247 channel,
248 Bytes::from_static(
249 b"remote: internal error during pre-receive validation\n",
250 ),
251 )
252 .await;
253 _ = handle.exit_status_request(channel, 1);
254 _ = handle.eof(channel);
255 _ = handle.close(channel);
256 return;
257 }
258 };
259 if let Err(e) = verdict.accepted_only() {
260 let msg = format!("remote: {e}\n");
261 _ = handle.data(channel, Bytes::from(msg.into_bytes())).await;
262 _ = handle.exit_status_request(channel, 1);
263 _ = handle.eof(channel);
264 _ = handle.close(channel);
265 return;
266 }
267 tracing::debug!(repo_id = %repo_id, "ssh pre-receive validation passed");
268 }
269 Ok(_) => {}
270 Err(e) => {
271 tracing::error!(
272 ?e, repo_id = %repo_id,
273 "receive-pack ref parse failed; refusing push"
274 );
275 let _ = handle
276 .data(
277 channel,
278 Bytes::from_static(b"remote: invalid receive-pack ref update\n"),
279 )
280 .await;
281 _ = handle.exit_status_request(channel, 1);
282 _ = handle.eof(channel);
283 _ = handle.close(channel);
284 return;
285 }
286 }
287 }
288
289 if is_write && let Some(bare) = op.as_ref().map(|o| o.bare().clone()) {
290 if let Err(e) = crate::sync::post_receive::prepare_post_receive(&bare) {
291 tracing::warn!(?e, "post-receive hook setup failed");
292 }
293 let push_key = format!("git_op:{}", bare.display());
294 tracing::debug!(push_key = %push_key, repo_id = ?repo_id, "ssh push: acquiring push lock");
295
296 let run = async {
297 run_git(
298 op.as_ref(),
299 &buffer,
300 &handle,
301 channel,
302 rpc_timeout,
303 nak_sent,
304 )
305 .await
306 };
307 let result = push_coordinator
308 .run(&push_key, async {
309 run.await.map_err(|e| SmartError::Internal(e.to_string()))
310 })
311 .await
312 .map_err(|e: SmartError| SshError::from(e));
313 if let Err(e) = &result {
314 tracing::warn!(repo_id = ?repo_id, error = %e, "ssh push failed");
315 }
316 if result.is_ok()
317 && let (Some(repo_id), Some(space_id), Some(queue)) =
318 (repo_id, space_id, &queue)
319 {
320 enqueue_post_receive_push_with_ci_logged(
321 queue,
322 &sync_settings,
323 ci_topic.as_deref(),
324 &crate::sync::PushTarget {
325 space_id,
326 repo_id,
327 actor_id,
328 bare: &bare,
329 },
330 )
331 .await;
332 }
333 let code = if result.is_ok() { 0 } else { 1 };
334 tracing::debug!(
335 ?channel,
336 code,
337 nak_sent,
338 "ssh: sending exit-status for push"
339 );
340 let r1 = handle.exit_status_request(channel, code).await;
341 // Windows OpenSSH can drop buffered channel data if CLOSE
342 // races ahead of the client's read loop; give the client time
343 // to drain the report before tearing the channel down.
344 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
345 let r2 = handle.eof(channel).await;
346 let r3 = handle.close(channel).await;
347 tracing::debug!(exit_status = ?r1, eof = ?r2, close = ?r3, "ssh: channel shutdown results");
348 } else {
349 let result = run_git(
350 op.as_ref(),
351 &buffer,
352 &handle,
353 channel,
354 rpc_timeout,
355 nak_sent,
356 )
357 .await;
358 let code = if result.is_ok() { 0 } else { 1 };
359 _ = handle.exit_status_request(channel, code);
360 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
361 _ = handle.eof(channel);
362 _ = handle.close(channel);
363 }
364 });
365 }
366}
367
368/// Start a duplex git session for `upload-pack` / `upload-archive`: stdin is fed
369/// from `rx` and stdout is streamed back as it is produced. The client keeps the
370/// channel open while it waits for the packfile, so the response cannot be
371/// deferred until EOF the way the push path does.
372pub(crate) fn spawn_streaming_git(
373 channel: russh::ChannelId,
374 state: ChannelState,
375 handle: russh::server::Handle,
376 rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
377) -> tokio::task::AbortHandle {
378 let op = state.git_op.clone();
379 let service = state.git_op.as_ref().map(|o| o.service_arg());
380 let timeout_secs = state.rpc_timeout_secs;
381 let task = tokio::spawn(async move {
382 let Some(op) = op else {
383 return;
384 };
385 let result = streaming_git(&op, &handle, channel, rx, timeout_secs).await;
386 export::metrics::git_op(
387 op.service_arg(),
388 if result.is_ok() { "ok" } else { "error" },
389 );
390 match &result {
391 Ok(()) => tracing::debug!(?channel, "ssh streaming git finished"),
392 Err(e) => tracing::warn!(?channel, error = %e, "ssh streaming git failed"),
393 }
394 let code = if result.is_ok() { 0 } else { 1 };
395 _ = handle.exit_status_request(channel, code).await;
396 // Give the client a moment to drain the final bytes before the
397 // channel is torn down (the same race the push path guards against).
398 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
399 _ = handle.eof(channel).await;
400 _ = handle.close(channel).await;
401 });
402 let abort = task.abort_handle();
403 if let Some(name) = service {
404 tracing::debug!(?channel, service = %name, "ssh streaming git started");
405 }
406 abort
407}
408
409/// Drive one duplex git process: pump `rx` into stdin, forward stdout to the
410/// SSH channel, and collect stderr for diagnostics.
411#[tracing::instrument(name = "git.ssh", skip_all, fields(service = op.service_arg()))]
412async fn streaming_git(
413 op: &crate::ssh::handler::git_op::GitOperation,
414 handle: &russh::server::Handle,
415 channel: russh::ChannelId,
416 mut rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
417 timeout_secs: u64,
418) -> Result<(), SshError> {
419 // Stateful, like a real sshd: advertise refs, then answer the request
420 // arriving on stdin. Must not be gated on stdin EOF.
421 let mut cmd = tokio::process::Command::new("git");
422 cmd.current_dir(op.bare()).arg(op.service_arg());
423 cmd.arg(op.bare())
424 .env("GIT_DIR", op.bare())
425 .env("GIT_TERMINAL_PROMPT", "0")
426 .stdin(std::process::Stdio::piped())
427 .stdout(std::process::Stdio::piped())
428 .stderr(std::process::Stdio::piped());
429 apply_hardened_env(&mut cmd);
430
431 cmd.kill_on_drop(true);
432 let mut child = cmd.spawn().map_err(SshError::Io)?;
433 let mut stdin = child
434 .stdin
435 .take()
436 .ok_or_else(|| SshError::Internal("git stdin missing".to_string()))?;
437 let mut stdout = child
438 .stdout
439 .take()
440 .ok_or_else(|| SshError::Internal("git stdout missing".to_string()))?;
441 let stderr = child
442 .stderr
443 .take()
444 .ok_or_else(|| SshError::Internal("git stderr missing".to_string()))?;
445
446 tracing::debug!(
447 service = op.service_arg(),
448 bare = %op.bare().display(),
449 "spawned git process for duplex ssh operation"
450 );
451
452 let writer = tokio::spawn(async move {
453 while let Some(chunk) = rx.recv().await {
454 if stdin.write_all(&chunk).await.is_err() {
455 break;
456 }
457 }
458 let _ = stdin.shutdown().await;
459 });
460
461 let reader = {
462 let handle = handle.clone();
463 tokio::spawn(async move {
464 let mut buf = vec![0u8; 64 * 1024];
465 loop {
466 match stdout.read(&mut buf).await {
467 Ok(0) => break,
468 Ok(n) => {
469 if handle
470 .data(channel, Bytes::copy_from_slice(&buf[..n]))
471 .await
472 .is_err()
473 {
474 break;
475 }
476 }
477 Err(e) => {
478 tracing::warn!(?channel, error = %e, "ssh: git stdout read failed");
479 break;
480 }
481 }
482 }
483 })
484 };
485
486 let stderr_task = tokio::spawn(async move {
487 let mut buf = Vec::new();
488 let _ = stderr
489 .take(MAX_GIT_OUTPUT_BUF as u64 + 1)
490 .read_to_end(&mut buf)
491 .await;
492 buf
493 });
494
495 let status = match tokio::time::timeout(
496 std::time::Duration::from_secs(timeout_secs),
497 child.wait(),
498 )
499 .await
500 {
501 Ok(Ok(s)) => s,
502 _ => {
503 let _ = child.start_kill();
504 let _ = child.wait().await;
505 return Err(SshError::Internal(format!(
506 "git {} timed out",
507 op.service_arg()
508 )));
509 }
510 };
511
512 // The writer only stops on client half-close, so awaiting it here would
513 // hang once the process has already finished.
514 let _ = reader.await;
515 writer.abort();
516 let _ = writer.await;
517 let stderr_bytes = stderr_task.await.unwrap_or_default();
518 tracing::debug!(
519 service = op.service_arg(),
520 exit = ?status.code(),
521 stderr_bytes = stderr_bytes.len(),
522 "git process for duplex ssh operation finished"
523 );
524 if !stderr_bytes.is_empty() && !status.success() {
525 let _ = handle
526 .extended_data(channel, 1, Bytes::from(stderr_bytes.clone()))
527 .await;
528 }
529 if status.success() {
530 Ok(())
531 } else {
532 Err(SshError::Internal(format!(
533 "git {} failed: exit {:?}",
534 op.service_arg(),
535 status.code()
536 )))
537 }
538}
539
540#[tracing::instrument(
541 name = "git.ssh",
542 skip_all,
543 fields(service = op.map(|o| o.service_arg()).unwrap_or("none"))
544)]
545async fn run_git(
546 op: Option<&crate::ssh::handler::git_op::GitOperation>,
547 input: &[u8],
548 handle: &russh::server::Handle,
549 channel: russh::ChannelId,
550 timeout_secs: u64,
551 strip_nak: bool,
552) -> Result<(), SshError> {
553 let Some(op) = op else {
554 return Ok(());
555 };
556 let result: Result<(), SshError> = async {
557 let mut cmd = tokio::process::Command::new("git");
558 cmd.current_dir(op.bare()).arg(op.service_arg());
559 if !op.is_upload_archive() {
560 cmd.arg("--stateless-rpc");
561 }
562 // Same as the advertise path: receive-pack requires an explicit
563 // <git-dir> argument, GIT_DIR alone is not enough.
564 cmd.arg(op.bare());
565 cmd.env("GIT_DIR", op.bare())
566 .env("GIT_TERMINAL_PROMPT", "0")
567 .stdin(std::process::Stdio::piped())
568 .stdout(std::process::Stdio::piped())
569 .stderr(std::process::Stdio::piped());
570 apply_hardened_env(&mut cmd);
571
572 cmd.kill_on_drop(true);
573 let mut child = cmd.spawn().map_err(SshError::Io)?;
574 tracing::debug!(
575 service = op.service_arg(),
576 bare = %op.bare().display(),
577 input_bytes = input.len(),
578 timeout_secs,
579 "spawned git process for ssh operation"
580 );
581 let mut stdin = child
582 .stdin
583 .take()
584 .ok_or_else(|| SshError::Internal("git stdin missing".to_string()))?;
585 let stdout = child
586 .stdout
587 .take()
588 .ok_or_else(|| SshError::Internal("git stdout missing".to_string()))?;
589 let stderr = child
590 .stderr
591 .take()
592 .ok_or_else(|| SshError::Internal("git stderr missing".to_string()))?;
593 let child_arc = Arc::new(tokio::sync::Mutex::new(Some(child)));
594
595 {
596 let child_arc = child_arc.clone();
597 let input = input.to_vec();
598 tokio::spawn(async move {
599 if stdin.write_all(&input).await.is_err()
600 && let Some(mut ch) = child_arc.lock().await.take()
601 {
602 let _ = ch.start_kill();
603 let _ = ch.wait().await;
604 }
605 let _ = stdin.shutdown().await;
606 });
607 }
608
609 let stdout_task = tokio::spawn(async move {
610 let cap = MAX_GIT_OUTPUT_BUF as u64 + 1;
611 let mut buf = Vec::new();
612 let res = stdout.take(cap).read_to_end(&mut buf).await;
613 (buf, res)
614 });
615 let stderr_task = tokio::spawn(async move {
616 let cap = MAX_GIT_OUTPUT_BUF as u64 + 1;
617 let mut buf = Vec::new();
618 let res = stderr.take(cap).read_to_end(&mut buf).await;
619 (buf, res)
620 });
621
622 let wait_task = {
623 let child_arc = child_arc.clone();
624 async move {
625 let mut guard = child_arc.lock().await;
626 match guard.as_mut() {
627 None => None,
628 Some(ch) => {
629 match tokio::time::timeout(
630 std::time::Duration::from_secs(timeout_secs),
631 ch.wait(),
632 )
633 .await
634 {
635 Ok(Ok(s)) => Some(s),
636 _ => {
637 let _ = ch.start_kill();
638 let _ = ch.wait().await;
639 None
640 }
641 }
642 }
643 }
644 }
645 };
646
647 let (wait_out, stdout_res, stderr_res) = tokio::join!(wait_task, stdout_task, stderr_task);
648
649 let (stdout_buf, stdout_read) =
650 stdout_res.map_err(|e| SshError::Internal(format!("stdout task: {e}")))?;
651 let (stderr_buf, _stderr_read) =
652 stderr_res.map_err(|e| SshError::Internal(format!("stderr task: {e}")))?;
653 let status = wait_out
654 .ok_or_else(|| SshError::Internal(format!("git {} timed out", op.service_arg())))?;
655
656 if stdout_buf.len() > MAX_GIT_OUTPUT_BUF || stderr_buf.len() > MAX_GIT_OUTPUT_BUF {
657 return Err(SshError::Internal(format!(
658 "git {} output exceeded {} bytes",
659 op.service_arg(),
660 MAX_GIT_OUTPUT_BUF
661 )));
662 }
663 stdout_read.map_err(SshError::Io)?;
664 let mut stdout_buf = stdout_buf;
665 if strip_nak {
666 // We already answered the wants section with a synthetic NAK, so
667 // remove receive-pack's own NAK+flush to keep the client's
668 // negotiation read aligned with the report that follows.
669 const NAK: &[u8] = b"0008NAK\n";
670 if stdout_buf.starts_with(NAK) {
671 let mut off = NAK.len();
672 if stdout_buf.get(off..off + 4) == Some(b"0000") {
673 off += 4;
674 }
675 stdout_buf.drain(..off);
676 }
677 }
678 tracing::debug!(
679 service = op.service_arg(),
680 exit = ?status.code(),
681 stdout_bytes = stdout_buf.len(),
682 stderr_bytes = stderr_buf.len(),
683 "git process for ssh operation finished"
684 );
685 if !stdout_buf.is_empty()
686 && let Err(e) = handle.data(channel, Bytes::from(stdout_buf.clone())).await
687 {
688 tracing::warn!(
689 ?e,
690 bytes = stdout_buf.len(),
691 "ssh: failed to send git stdout to client"
692 );
693 }
694 if !stderr_buf.is_empty()
695 && !status.success()
696 && let Err(e) = handle
697 .extended_data(channel, 1, Bytes::from(stderr_buf.clone()))
698 .await
699 {
700 tracing::warn!(?e, "ssh: failed to send git stderr to client");
701 }
702 if status.success() {
703 Ok(())
704 } else {
705 Err(SshError::Internal(format!(
706 "git {} failed: exit {:?}",
707 op.service_arg(),
708 status.code()
709 )))
710 }
711 }
712 .await;
713 export::metrics::git_op(
714 op.service_arg(),
715 if result.is_ok() { "ok" } else { "error" },
716 );
717 result
718}
719
720/// Build a GitBaby for `validate_pre_receive`. The SSH path does not have
721/// `(space, repo)` strings available — only `repo_id` — so we resolve the
722/// repo model directly and assemble a `GitContext` from `AppState`. This
723/// mirrors `service::context::context` minus the space-name lookup, which
724/// is unnecessary here because `validate_pre_receive` only needs the bare
725/// repo metadata + a configured repo_root/objects_root.
726async fn build_baby_for_pre_receive(
727 service: &Arc<crate::ssh::service::GitSshService>,
728 repo_id: uuid::Uuid,
729) -> Option<gitbaby::GitBaby> {
730 use models::repo::repos;
731 let db = service.smart.git.state.database.clone();
732 let repo = repos::Entity::find_by_id(repo_id)
733 .one(db.read())
734 .await
735 .ok()??;
736 let cache = Arc::new(CacheClient::new(service.smart.git.state.redis.clone()));
737 let git_cfg = &service.smart.git.state.config.git;
738 let ctx = GitContext {
739 model: repo,
740 cache,
741 database: db,
742 repo_root: std::path::PathBuf::from(&git_cfg.repo_root),
743 objects_root: std::path::PathBuf::from(&git_cfg.objects_root),
744 };
745 Some(service.smart.git.baby(&ctx))
746}
Deleted lib/git/src/ssh/handler/command.rs
@@ -1,293 +0,0 @@
1use bytes::Bytes;
2use russh::ChannelId;
3use russh::server::Session;
4
5use uuid::Uuid;
6
7use crate::share::access::authorize_repo_access;
8use crate::share::errors::GitError;
9use crate::smart::error::SmartError;
10use crate::ssh::error::SshError;
11use crate::ssh::git_service::{
12 GitService, build_git_advertise_command, parse_git_command, parse_repo_path, sanitize_for_log,
13};
14use crate::ssh::handler::GitSshHandler;
15use crate::ssh::handler::git_op::GitOperation;
16
17impl GitSshHandler {
18 pub async fn exec_request_impl(
19 &mut self,
20 channel: ChannelId,
21 data: &[u8],
22 session: &mut Session,
23 ) -> Result<(), russh::Error> {
24 let cmd = match std::str::from_utf8(data) {
25 Ok(c) => c,
26 Err(_) => {
27 invalid_command("invalid command encoding", session, channel).await;
28 return Ok(());
29 }
30 };
31 let cmd = cmd.trim();
32 tracing::info!(
33 "ssh exec from {:?}: {}",
34 self.client_addr,
35 sanitize_for_log(cmd)
36 );
37
38 let Some((service, raw_path)) = parse_git_command(cmd) else {
39 invalid_command("invalid git command", session, channel).await;
40 return Ok(());
41 };
42 let Some((space_name, repo_name)) = parse_repo_path(raw_path) else {
43 tracing::debug!(path = %sanitize_for_log(raw_path), "ssh exec: invalid repository path");
44 invalid_command("invalid repository path", session, channel).await;
45 return Ok(());
46 };
47 tracing::debug!(
48 service = service.as_subcommand(),
49 space = space_name,
50 repo = repo_name,
51 "ssh exec: parsed git command"
52 );
53
54 let actor_id = self.operator.as_ref().map(|u| u.uid);
55 let required = if service.is_write() { "write" } else { "read" };
56
57 if let Err(msg) = self
58 .authorize_repo(space_name, repo_name, service.is_write())
59 .await
60 {
61 tracing::warn!("ssh {service:?} {space_name}/{repo_name} denied: {msg}");
62 invalid_command(&msg, session, channel).await;
63 return Ok(());
64 }
65
66 let ctx = match self.service.smart.resolve(space_name, repo_name).await {
67 Ok(c) => c,
68 Err(e) => {
69 tracing::warn!(space = space_name, repo = repo_name, error = %e, "ssh exec: repo resolve failed");
70 let msg = format!("fatal: {e}");
71 invalid_command(&msg, session, channel).await;
72 return Ok(());
73 }
74 };
75 tracing::debug!(
76 space = space_name,
77 repo = repo_name,
78 repo_id = %ctx.model.id,
79 "ssh exec: repo access authorized and resolved"
80 );
81 let space_id = match self.space_id_for(&ctx.model.dir_id).await {
82 Ok(id) => id,
83 Err(error) => {
84 tracing::warn!(%error, "space lookup failed, falling back to dir id");
85 ctx.model.dir_id
86 }
87 };
88
89 let bare = ctx.repo_dir();
90 if !bare.is_dir() {
91 tracing::warn!(bare = %bare.display(), "ssh exec: bare repository directory missing");
92 let msg = format!("fatal: repository '{space_name}/{repo_name}' does not exist");
93 invalid_command(&msg, session, channel).await;
94 return Ok(());
95 }
96
97 // Push is driven from a fully buffered request, so receive-pack only
98 // runs after EOF and its advertisement is produced up front here.
99 // Upload services are duplex and emit the advertisement themselves.
100 let mut adv_len = 0usize;
101 if service.is_write() {
102 let adv = match run_advertise(
103 &bare,
104 service,
105 self.service.smart.git.state.config.git.git_rpc_timeout_secs,
106 )
107 .await
108 {
109 Ok(adv) => adv,
110 Err(e) => {
111 tracing::warn!(
112 service = service.as_subcommand(),
113 bare = %bare.display(),
114 error = %e,
115 "ssh exec: ref advertisement failed"
116 );
117 let msg = format!("fatal: unable to read repository advertisement: {e}");
118 invalid_command(&msg, session, channel).await;
119 return Ok(());
120 }
121 };
122 adv_len = adv.len();
123 if !adv.is_empty() {
124 let _ = session.data(channel, Bytes::from(adv));
125 }
126 }
127 let _ = session.channel_success(channel);
128 let _ = session.flush();
129 tracing::debug!(
130 service = service.as_subcommand(),
131 repo_id = %ctx.model.id,
132 adv_bytes = adv_len,
133 "ssh exec: channel armed"
134 );
135
136 let op = match service {
137 GitService::UploadPack => GitOperation::UploadPack { bare: bare.clone() },
138 GitService::UploadArchive => GitOperation::UploadArchive { bare: bare.clone() },
139 GitService::ReceivePack => GitOperation::ReceivePack { bare: bare.clone() },
140 };
141 // Fetch/clone/archive is duplex: waiting for EOF before starting git,
142 // as the push path does, would deadlock against a client that is
143 // waiting for the packfile.
144 let streaming = !op.is_write();
145 {
146 let state = self.channels.entry(channel).or_default();
147 state.git_op = Some(op);
148 state.repo_id = Some(ctx.model.id);
149 state.space_id = Some(space_id);
150 state.actor_id = actor_id;
151 }
152 if streaming {
153 let state = self.channels.remove(&channel).expect("channel state");
154 let mut state = state;
155 state.rpc_timeout_secs = self.service.smart.git.state.config.git.git_rpc_timeout_secs;
156 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
157 let abort = crate::ssh::handler::channel::spawn_streaming_git(
158 channel,
159 state,
160 session.handle(),
161 rx,
162 );
163 let state = self.channels.entry(channel).or_default();
164 state.dispatched = true;
165 state.stdin_tx = Some(tx);
166 state.stream_abort = Some(abort);
167 }
168 let _ = required;
169 Ok(())
170 }
171
172 async fn authorize_repo(
173 &self,
174 space: &str,
175 repo: &str,
176 want_write: bool,
177 ) -> Result<(), String> {
178 let svc = &self.service.smart.git;
179 let ctx = svc
180 .context(space.to_string(), repo.to_string())
181 .await
182 .map_err(|e| format!("{e}"))?;
183 let Some(operator) = &self.operator else {
184 return Err("authentication required".into());
185 };
186 authorize_repo_access(svc, operator.uid, &ctx, want_write, false)
187 .await
188 .map_err(|e| match e {
189 GitError::Forbidden => "permission denied".to_string(),
190 other => format!("{other}"),
191 })
192 }
193
194 async fn space_id_for(&self, dir_id: &Uuid) -> Result<Uuid, SmartError> {
195 crate::share::access::space_for_dir(self.service.smart.git.db_read(), *dir_id)
196 .await
197 .map_err(SmartError::from)?
198 .map(|s| s.id)
199 .ok_or_else(|| SmartError::NotFound("space".into()))
200 }
201
202 pub async fn shell_request_impl(
203 &mut self,
204 channel: ChannelId,
205 session: &mut Session,
206 ) -> Result<(), russh::Error> {
207 tracing::debug!(client = ?self.client_addr, "interactive shell request denied (git-only server)");
208 let _ = session.data(
209 channel,
210 Bytes::from_static(
211 b"Welcome to GitData SSH. This is a git-only server; shell access is disabled.\r\n",
212 ),
213 );
214 let _ = session.exit_status_request(channel, 0);
215 let _ = session.eof(channel);
216 let _ = session.close(channel);
217 let _ = session.flush();
218 Ok(())
219 }
220}
221
222async fn invalid_command(msg: &str, session: &mut Session, channel: ChannelId) {
223 let _ = session.data(channel, Bytes::from(format!("{msg}\n").into_bytes()));
224 let _ = session.channel_failure(channel);
225 let _ = session.exit_status_request(channel, 1);
226 let _ = session.eof(channel);
227 let _ = session.close(channel);
228 let _ = session.flush();
229}
230
231async fn run_advertise(
232 bare: &std::path::Path,
233 service: GitService,
234 timeout_secs: u64,
235) -> Result<Vec<u8>, SshError> {
236 let mut cmd = build_git_advertise_command(service, bare);
237 cmd.stdout(std::process::Stdio::piped())
238 .stderr(std::process::Stdio::piped());
239 let mut child = cmd.spawn().map_err(SshError::Io)?;
240 let stderr_task = child.stderr.take().map(|stderr| {
241 tokio::spawn(async move {
242 use tokio::io::AsyncReadExt;
243 let s = stderr;
244 let mut buf = Vec::new();
245 let _ = s
246 .take((64 * 1024 * 1024) as u64 + 1)
247 .read_to_end(&mut buf)
248 .await;
249 buf
250 })
251 });
252 let stdout = child
253 .stdout
254 .take()
255 .ok_or_else(|| SshError::Internal("git stdout missing".to_string()))?;
256 let mut out = Vec::new();
257 use tokio::io::AsyncReadExt;
258 stdout
259 .take((64 * 1024 * 1024) as u64 + 1)
260 .read_to_end(&mut out)
261 .await
262 .map_err(SshError::Io)?;
263 let status = match tokio::time::timeout(
264 std::time::Duration::from_secs(timeout_secs),
265 child.wait(),
266 )
267 .await
268 {
269 Ok(Ok(s)) => s,
270 _ => {
271 let _ = child.start_kill();
272 let _ = child.wait().await;
273 return Err(SshError::Internal(format!(
274 "git {} --advertise-refs timed out",
275 service.as_subcommand()
276 )));
277 }
278 };
279 let stderr_bytes = match stderr_task {
280 Some(t) => t
281 .await
282 .map_err(|e| SshError::Internal(format!("stderr task join failed: {e}")))?,
283 None => Vec::new(),
284 };
285 if !status.success() {
286 return Err(SshError::Internal(format!(
287 "git {} --advertise-refs failed: {}",
288 service.as_subcommand(),
289 String::from_utf8_lossy(&stderr_bytes)
290 )));
291 }
292 Ok(out)
293}
Deleted lib/git/src/ssh/handler/git_op.rs
@@ -1,34 +0,0 @@
1use std::path::PathBuf;
2
3#[derive(Clone, Debug)]
4pub enum GitOperation {
5 UploadPack { bare: PathBuf },
6 ReceivePack { bare: PathBuf },
7 UploadArchive { bare: PathBuf },
8}
9
10impl GitOperation {
11 pub fn bare(&self) -> &PathBuf {
12 match self {
13 Self::UploadPack { bare }
14 | Self::ReceivePack { bare }
15 | Self::UploadArchive { bare } => bare,
16 }
17 }
18
19 pub fn is_write(&self) -> bool {
20 matches!(self, Self::ReceivePack { .. })
21 }
22
23 pub fn is_upload_archive(&self) -> bool {
24 matches!(self, Self::UploadArchive { .. })
25 }
26
27 pub fn service_arg(&self) -> &'static str {
28 match self {
29 Self::UploadPack { .. } => "upload-pack",
30 Self::ReceivePack { .. } => "receive-pack",
31 Self::UploadArchive { .. } => "upload-archive",
32 }
33 }
34}
Deleted lib/git/src/ssh/handler/mod.rs
@@ -1,215 +0,0 @@
1use std::collections::HashMap;
2use std::net::SocketAddr;
3use std::sync::Arc;
4
5use russh::server::{Auth, Handler, Msg, Session};
6use russh::{Channel, ChannelId};
7use tokio::sync::OwnedSemaphorePermit;
8use uuid::Uuid;
9
10use models::users::user;
11
12use crate::ssh::handler::git_op::GitOperation;
13use crate::ssh::service::GitSshService;
14
15pub mod git_op;
16
17mod auth;
18pub(crate) mod channel;
19mod command;
20
21#[derive(Default)]
22pub struct ChannelState {
23 pub buffer: Vec<u8>,
24 pub git_op: Option<GitOperation>,
25 pub repo_id: Option<Uuid>,
26 pub space_id: Option<Uuid>,
27 pub actor_id: Option<i64>,
28 pub buffer_overflow: bool,
29 pub nak_sent: bool,
30 /// Set when a duplex git session (upload-pack / upload-archive) is running
31 /// for this channel; client data is then relayed to its stdin instead of
32 /// being buffered.
33 pub dispatched: bool,
34 pub stdin_tx: Option<tokio::sync::mpsc::UnboundedSender<Vec<u8>>>,
35 /// Abort handle for the task that owns the streaming git child process.
36 pub stream_abort: Option<tokio::task::AbortHandle>,
37 /// Wall-clock budget for the duplex git process, captured at exec time.
38 pub rpc_timeout_secs: u64,
39}
40
41pub struct GitSshHandler {
42 pub service: Arc<GitSshService>,
43 pub client_addr: Option<SocketAddr>,
44 pub session_permit: Option<OwnedSemaphorePermit>,
45 pub operator: Option<user::Model>,
46 pub channels: HashMap<ChannelId, ChannelState>,
47 pub over_capacity: bool,
48}
49
50impl GitSshHandler {
51 pub fn new(
52 service: Arc<GitSshService>,
53 client_addr: Option<SocketAddr>,
54 session_permit: Option<OwnedSemaphorePermit>,
55 ) -> Self {
56 let over_capacity = session_permit.is_none();
57 Self {
58 service,
59 client_addr,
60 session_permit,
61 operator: None,
62 channels: HashMap::new(),
63 over_capacity,
64 }
65 }
66
67 pub fn config(&self) -> &config::git::GitSshConfig {
68 &self.service.config
69 }
70}
71
72impl Handler for GitSshHandler {
73 type Error = russh::Error;
74
75 async fn auth_none(&mut self, user: &str) -> Result<Auth, Self::Error> {
76 tracing::debug!(user, client = ?self.client_addr, "ssh auth_none rejected");
77 Ok(Auth::reject())
78 }
79
80 async fn auth_password(&mut self, user: &str, _password: &str) -> Result<Auth, Self::Error> {
81 tracing::debug!(user, client = ?self.client_addr, "ssh password auth rejected (publickey only)");
82 Ok(Auth::reject())
83 }
84
85 async fn auth_publickey_offered(
86 &mut self,
87 user: &str,
88 public_key: &russh::keys::PublicKey,
89 ) -> Result<Auth, Self::Error> {
90 self.auth_publickey_offered_impl(user, public_key).await
91 }
92
93 async fn auth_publickey(
94 &mut self,
95 user: &str,
96 public_key: &russh::keys::PublicKey,
97 ) -> Result<Auth, Self::Error> {
98 self.auth_publickey_impl(user, public_key).await
99 }
100
101 async fn auth_openssh_certificate(
102 &mut self,
103 user: &str,
104 _certificate: &russh::keys::Certificate,
105 ) -> Result<Auth, Self::Error> {
106 tracing::debug!(user, "ssh certificate auth rejected");
107 Ok(Auth::reject())
108 }
109
110 async fn authentication_banner(&mut self) -> Result<Option<String>, Self::Error> {
111 Ok(self.config().banner.clone())
112 }
113
114 async fn channel_open_session(
115 &mut self,
116 _channel: Channel<Msg>,
117 reply: russh::server::ChannelOpenHandle,
118 _session: &mut Session,
119 ) -> Result<(), Self::Error> {
120 reply.accept().await;
121 Ok(())
122 }
123
124 async fn channel_close(
125 &mut self,
126 channel: ChannelId,
127 _session: &mut Session,
128 ) -> Result<(), Self::Error> {
129 self.channel_close_impl(channel).await;
130 Ok(())
131 }
132
133 async fn channel_eof(
134 &mut self,
135 channel: ChannelId,
136 session: &mut Session,
137 ) -> Result<(), Self::Error> {
138 self.channel_eof_impl(channel, session).await
139 }
140
141 async fn data(
142 &mut self,
143 channel: ChannelId,
144 data: &[u8],
145 session: &mut Session,
146 ) -> Result<(), Self::Error> {
147 self.data_impl(channel, data, session).await
148 }
149
150 async fn exec_request(
151 &mut self,
152 channel: ChannelId,
153 data: &[u8],
154 session: &mut Session,
155 ) -> Result<(), Self::Error> {
156 self.exec_request_impl(channel, data, session).await
157 }
158
159 async fn pty_request(
160 &mut self,
161 _channel: ChannelId,
162 _term: &str,
163 _col_width: u32,
164 _row_height: u32,
165 _pix_width: u32,
166 _pix_height: u32,
167 _modes: &[(russh::Pty, u32)],
168 session: &mut Session,
169 ) -> Result<(), Self::Error> {
170 let _ = session.flush();
171 Ok(())
172 }
173
174 async fn shell_request(
175 &mut self,
176 channel: ChannelId,
177 session: &mut Session,
178 ) -> Result<(), Self::Error> {
179 self.shell_request_impl(channel, session).await
180 }
181
182 async fn subsystem_request(
183 &mut self,
184 _channel: ChannelId,
185 _name: &str,
186 session: &mut Session,
187 ) -> Result<(), Self::Error> {
188 let _ = session.flush();
189 Ok(())
190 }
191
192 async fn env_request(
193 &mut self,
194 _channel: ChannelId,
195 _variable_name: &str,
196 _variable_value: &str,
197 session: &mut Session,
198 ) -> Result<(), Self::Error> {
199 let _ = session.flush();
200 Ok(())
201 }
202
203 async fn window_change_request(
204 &mut self,
205 _channel: ChannelId,
206 _col_width: u32,
207 _row_height: u32,
208 _pix_width: u32,
209 _pix_height: u32,
210 session: &mut Session,
211 ) -> Result<(), Self::Error> {
212 let _ = session.flush();
213 Ok(())
214 }
215}
Deleted lib/git/src/ssh/error.rs
@@ -1,40 +0,0 @@
1#[derive(Debug, thiserror::Error)]
2pub enum SshError {
3 #[error("authentication rejected: {0}")]
4 AuthRejected(String),
5 #[error("failed to load host key: {0}")]
6 KeyLoadFailed(String),
7 #[error("repository not found: {0}")]
8 RepoNotFound(String),
9 #[error("permission denied: {0}")]
10 PermissionDenied(String),
11 #[error("invalid command: {0}")]
12 InvalidCommand(String),
13 #[error("io error: {0}")]
14 Io(#[from] std::io::Error),
15 #[error("database error: {0}")]
16 Db(#[from] sea_orm::DbErr),
17 #[error("ssh error: {0}")]
18 Ssh(#[from] russh::Error),
19 #[error("internal error: {0}")]
20 Internal(String),
21}
22
23pub type SshResult<T> = Result<T, SshError>;
24
25impl From<crate::smart::error::SmartError> for SshError {
26 fn from(err: crate::smart::error::SmartError) -> Self {
27 use crate::smart::error::SmartError;
28 match err {
29 SmartError::Unauthorized => Self::AuthRejected("authentication required".into()),
30 SmartError::BadRequest(m) => Self::InvalidCommand(m),
31 SmartError::Conflict(m) => Self::Internal(m),
32 SmartError::Forbidden(m) => Self::PermissionDenied(m),
33 SmartError::NotFound(m) => Self::RepoNotFound(m),
34 SmartError::Internal(m) => Self::Internal(m),
35 SmartError::Io(e) => Self::Io(e),
36 SmartError::Db(e) => Self::Db(e),
37 SmartError::Redis(e) => Self::Internal(format!("redis error: {e}")),
38 }
39 }
40}
Deleted lib/git/src/ssh/git_service.rs
@@ -1,110 +0,0 @@
1use std::path::Path;
2
3use tokio::process::Command;
4
5pub const MAX_REPO_PATH_LEN: usize = 512;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum GitService {
9 UploadPack,
10 ReceivePack,
11 UploadArchive,
12}
13
14impl GitService {
15 pub const fn as_subcommand(self) -> &'static str {
16 match self {
17 Self::UploadPack => "upload-pack",
18 Self::ReceivePack => "receive-pack",
19 Self::UploadArchive => "upload-archive",
20 }
21 }
22
23 pub const fn is_write(self) -> bool {
24 matches!(self, Self::ReceivePack)
25 }
26}
27
28impl std::str::FromStr for GitService {
29 type Err = ();
30
31 fn from_str(s: &str) -> Result<Self, Self::Err> {
32 match s {
33 "upload-pack" => Ok(Self::UploadPack),
34 "receive-pack" => Ok(Self::ReceivePack),
35 "upload-archive" => Ok(Self::UploadArchive),
36 _ => Err(()),
37 }
38 }
39}
40
41pub fn parse_git_command(cmd: &str) -> Option<(GitService, &str)> {
42 let (bin, path) = cmd.split_once(' ')?;
43 let service = match bin {
44 "git-upload-pack" => GitService::UploadPack,
45 "git-receive-pack" => GitService::ReceivePack,
46 "git-upload-archive" => GitService::UploadArchive,
47 _ => return None,
48 };
49 Some((service, path))
50}
51
52pub fn parse_repo_path(path: &str) -> Option<(&str, &str)> {
53 let path = path.trim().trim_matches('\'').trim_matches('/');
54 if path.is_empty() || path.len() > MAX_REPO_PATH_LEN {
55 return None;
56 }
57 for b in path.bytes() {
58 let ok =
59 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'_' | b'/');
60 if !ok {
61 return None;
62 }
63 }
64 let path = path.trim_end_matches(".git").trim_end_matches('/');
65 if path.is_empty() {
66 return None;
67 }
68 let (owner, repo) = path.split_once('/')?;
69 if owner.is_empty() || repo.is_empty() {
70 return None;
71 }
72 if owner == "." || owner == ".." || repo.starts_with("..") || repo.contains("/../") {
73 return None;
74 }
75 Some((owner, repo))
76}
77
78pub fn sanitize_for_log(input: &str) -> String {
79 let mut out = String::with_capacity(input.len().min(256));
80 for &b in input.as_bytes().iter().take(256) {
81 match b {
82 0x00..=0x1f | 0x7f => out.push('?'),
83 0x80..=0x9f => out.push('?'),
84 _ => out.push(b as char),
85 }
86 }
87 out
88}
89
90pub fn build_git_advertise_command(service: GitService, bare: &Path) -> Command {
91 let mut cmd = Command::new("git");
92 cmd.current_dir(bare)
93 .arg(service.as_subcommand())
94 .arg("--advertise-refs")
95 // receive-pack rejects a missing <git-dir> even when GIT_DIR is set,
96 // so the path must be passed explicitly.
97 .arg(bare)
98 .env("GIT_DIR", bare)
99 .env("GIT_TERMINAL_PROMPT", "0");
100 apply_hardened_env(&mut cmd);
101 cmd
102}
103
104pub fn apply_hardened_env(cmd: &mut Command) {
105 cmd.env("GIT_CONFIG_NOSYSTEM", "1")
106 .env("GIT_NO_REPLACE_OBJECTS", "1");
107 #[cfg(unix)]
108 cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
109 .env("GIT_CONFIG_SYSTEM", "/dev/null");
110}
Deleted lib/git/src/ssh/mod.rs
@@ -1,14 +0,0 @@
1pub mod error;
2pub mod git_service;
3pub mod handler;
4pub mod server;
5pub mod service;
6
7pub use error::{SshError, SshResult};
8pub use git_service::{
9 GitService, apply_hardened_env, build_git_advertise_command, parse_git_command,
10 parse_repo_path, sanitize_for_log,
11};
12pub use handler::GitSshHandler;
13pub use server::{build_server_config, load_host_key, run_ssh, serve_on_listener};
14pub use service::GitSshService;
Deleted lib/git/src/ssh/server.rs
@@ -1,99 +0,0 @@
1use std::net::SocketAddr;
2use std::sync::Arc;
3
4use russh::keys::PrivateKey;
5use russh::server::Server as _;
6use russh::{MethodKind, MethodSet, SshId, server};
7use tokio::sync::Semaphore;
8
9use config::git::GitSshConfig;
10
11use crate::ssh::error::{SshError, SshResult};
12use crate::ssh::handler::GitSshHandler;
13use crate::ssh::service::GitSshService;
14
15pub struct GitSshServer {
16 service: Arc<GitSshService>,
17 session_slots: Arc<Semaphore>,
18}
19
20impl GitSshServer {
21 pub fn new(service: Arc<GitSshService>) -> Self {
22 let max = service.config.max_sessions.max(1);
23 Self {
24 service,
25 session_slots: Arc::new(Semaphore::new(max)),
26 }
27 }
28}
29
30impl server::Server for GitSshServer {
31 type Handler = GitSshHandler;
32
33 fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler {
34 let permit = self.session_slots.clone().try_acquire_owned().ok();
35 if permit.is_none() {
36 tracing::warn!(?peer_addr, "ssh session limit reached; rejecting client");
37 } else {
38 tracing::info!(?peer_addr, "new ssh client connected");
39 }
40 GitSshHandler::new(self.service.clone(), peer_addr, permit)
41 }
42
43 fn handle_session_error(&mut self, error: russh::Error) {
44 tracing::debug!("ssh session error: {error}");
45 }
46}
47
48pub fn load_host_key(path: &str) -> SshResult<PrivateKey> {
49 let data = std::fs::read_to_string(path)
50 .map_err(|e| SshError::KeyLoadFailed(format!("cannot read {path}: {e}")))?;
51 let key = PrivateKey::from_openssh(&data)
52 .map_err(|e| SshError::KeyLoadFailed(format!("openssh decode failed for {path}: {e}")))?;
53 tracing::debug!(path, "ssh host key loaded");
54 Ok(key)
55}
56
57pub fn build_server_config(config: &GitSshConfig) -> SshResult<server::Config> {
58 let host_key = load_host_key(&config.host_key_path)?;
59 let mut methods = MethodSet::empty();
60 methods.push(MethodKind::PublicKey);
61 Ok(server::Config {
62 server_id: SshId::Standard(format!("SSH-2.0-gitdata-{}", env!("CARGO_PKG_VERSION")).into()),
63 methods,
64 keys: vec![host_key],
65 max_auth_attempts: 10,
66 auth_rejection_time: config.auth_rejection_time,
67 inactivity_timeout: config.inactivity_timeout,
68 keepalive_interval: Some(config.keepalive_interval),
69 keepalive_max: config.keepalive_max as usize,
70 ..Default::default()
71 })
72}
73
74pub async fn run_ssh(service: GitSshService) -> SshResult<()> {
75 let bind_addr = service.config.bind_addr.clone();
76 let port = service.config.port;
77 let max_sessions = service.config.max_sessions;
78 let config = Arc::new(build_server_config(&service.config)?);
79 let mut server = GitSshServer::new(Arc::new(service));
80 tracing::info!(%bind_addr, port, max_sessions, "starting git ssh server");
81 server
82 .run_on_address(config, format!("{bind_addr}:{port}"))
83 .await
84 .map_err(SshError::Io)?;
85 tracing::info!(%bind_addr, port, "git ssh server stopped");
86 Ok(())
87}
88
89pub async fn serve_on_listener(
90 service: GitSshService,
91 listener: tokio::net::TcpListener,
92) -> SshResult<()> {
93 let config = Arc::new(build_server_config(&service.config)?);
94 let mut server = GitSshServer::new(Arc::new(service));
95 tracing::info!(local_addr = ?listener.local_addr().ok(), "serving git ssh on listener");
96 let running = server.run_on_socket(config, &listener);
97 running.await.map_err(SshError::Io)?;
98 Ok(())
99}
Deleted lib/git/src/ssh/service.rs
@@ -1,40 +0,0 @@
1use std::sync::Arc;
2
3use crate::smart::push::{NoopPushBackend, PushCoordinator, PushOperations, RedisPushBackend};
4use crate::smart::service::SmartService;
5use config::git::{GitSshConfig, SyncSettings};
6
7#[derive(Clone)]
8pub struct GitSshService {
9 pub smart: SmartService,
10 pub config: GitSshConfig,
11 pub sync_settings: SyncSettings,
12}
13
14impl GitSshService {
15 pub fn new(smart: SmartService, config: GitSshConfig) -> Self {
16 Self {
17 smart,
18 config,
19 sync_settings: SyncSettings::default(),
20 }
21 }
22
23 pub fn with_sync_settings(mut self, settings: SyncSettings) -> Self {
24 self.sync_settings = settings;
25 self
26 }
27
28 pub fn push_coordinator(&self) -> PushCoordinator {
29 let backend: Arc<dyn PushOperations> = match &self.smart.kv {
30 Some(kv) => Arc::new(RedisPushBackend { kv: kv.clone() }),
31 None => Arc::new(NoopPushBackend),
32 };
33 PushCoordinator::new(backend)
34 }
35
36 pub fn with_ci_event_topic(mut self, topic: Option<String>) -> Self {
37 self.smart = self.smart.with_ci_event_topic(topic);
38 self
39 }
40}
Deleted lib/git/src/sync/branches.rs
@@ -1,176 +0,0 @@
1use std::collections::HashMap;
2
3use gitbaby::GitBaby;
4use gitbaby::branch::ListBranchesOptions;
5use models::git::git_branches;
6use models::repo::repos;
7use sea_orm::{
8 ActiveModelTrait, ColumnTrait, DatabaseTransaction, EntityTrait, IntoActiveModel, QueryFilter,
9 Set,
10};
11use time::OffsetDateTime;
12use uuid::Uuid;
13
14use crate::sync::error::{SyncError, SyncResult};
15
16const PREFERRED_DEFAULT_BRANCHES: &[&str] = &["main", "master", "trunk"];
17
18#[derive(Debug, Clone)]
19pub(crate) struct BranchTip {
20 pub name: String,
21 pub target_sha: String,
22}
23
24pub async fn sync_branches(
25 baby: &GitBaby,
26 txn: &DatabaseTransaction,
27 repo: &repos::Model,
28) -> SyncResult<usize> {
29 let collected = collect_branch_tips(baby).await?;
30
31 let existing = git_branches::Entity::find()
32 .filter(git_branches::Column::RepoId.eq(repo.id))
33 .all(txn)
34 .await
35 .map_err(SyncError::Db)?;
36
37 let mut existing_by_name: HashMap<String, git_branches::Model> = existing
38 .into_iter()
39 .filter(|b| !b.is_deleted)
40 .map(|b| (b.name.clone(), b))
41 .collect();
42
43 tracing::debug!(
44 repo_id = %repo.id,
45 tips = collected.len(),
46 db_branches = existing_by_name.len(),
47 "syncing branches"
48 );
49
50 let mut stale: std::collections::HashSet<String> = existing_by_name.keys().cloned().collect();
51 let (mut updated, mut inserted) = (0usize, 0usize);
52
53 for tip in &collected {
54 stale.remove(&tip.name);
55 match existing_by_name.get(&tip.name) {
56 Some(row) if row.commit_sha != tip.target_sha => {
57 let mut am = row.clone().into_active_model();
58 am.commit_sha = Set(tip.target_sha.clone());
59 am.updated_at = Set(OffsetDateTime::now_utc());
60 am.update(txn).await.map_err(SyncError::Db)?;
61 updated += 1;
62 }
63 Some(_) => {}
64 None => {
65 let now = OffsetDateTime::now_utc();
66 git_branches::ActiveModel {
67 id: Set(Uuid::now_v7()),
68 repo_id: Set(repo.id),
69 name: Set(tip.name.clone()),
70 commit_sha: Set(tip.target_sha.clone()),
71 is_deleted: Set(false),
72 is_protected: Set(false),
73 deleted_at: Set(None),
74 created_at: Set(now),
75 updated_at: Set(now),
76 }
77 .insert(txn)
78 .await
79 .map_err(SyncError::Db)?;
80 inserted += 1;
81 }
82 }
83 }
84
85 let mut deleted = 0usize;
86 for name in stale {
87 if existing_by_name.get(&name).is_some_and(|r| r.is_protected) {
88 tracing::debug!(branch = %name, "stale branch is protected; keeping row");
89 continue;
90 }
91 let Some(row) = existing_by_name.remove(&name) else {
92 continue;
93 };
94 let mut am = row.into_active_model();
95 am.is_deleted = Set(true);
96 am.deleted_at = Set(Some(OffsetDateTime::now_utc()));
97 am.updated_at = Set(OffsetDateTime::now_utc());
98 am.update(txn).await.map_err(SyncError::Db)?;
99 deleted += 1;
100 }
101 tracing::debug!(
102 repo_id = %repo.id,
103 updated,
104 inserted,
105 deleted,
106 "branch sync wrote changes"
107 );
108
109 reconcile_default_branch(baby, txn, repo, &collected).await?;
110
111 Ok(collected.len())
112}
113
114pub(crate) async fn collect_branch_tips(baby: &GitBaby) -> SyncResult<Vec<BranchTip>> {
115 let branches = baby
116 .list_branches(ListBranchesOptions::default())
117 .await
118 .map_err(SyncError::Git)?;
119 Ok(branches
120 .into_iter()
121 .map(|b| BranchTip {
122 name: b.name,
123 target_sha: b.sha.to_string(),
124 })
125 .collect())
126}
127
128async fn reconcile_default_branch(
129 baby: &GitBaby,
130 txn: &DatabaseTransaction,
131 repo: &repos::Model,
132 collected: &[BranchTip],
133) -> SyncResult<()> {
134 if collected.is_empty() {
135 return Ok(());
136 }
137
138 let desired = if collected.iter().any(|b| b.name == repo.default_branch) {
139 repo.default_branch.clone()
140 } else {
141 PREFERRED_DEFAULT_BRANCHES
142 .iter()
143 .copied()
144 .find(|name| collected.iter().any(|b| b.name == *name))
145 .or_else(|| collected.first().map(|b| b.name.as_str()))
146 .unwrap_or(&repo.default_branch)
147 .to_string()
148 };
149
150 if desired != repo.default_branch {
151 tracing::info!(
152 repo_id = %repo.id,
153 old = %repo.default_branch,
154 new = %desired,
155 "default branch changed during sync"
156 );
157 let mut am = repo.clone().into_active_model();
158 am.default_branch = Set(desired.clone());
159 am.update(txn).await.map_err(SyncError::Db)?;
160 }
161
162 let head_ok = matches!(
163 baby.get_default_branch().await,
164 Ok(current) if current == desired
165 );
166 if !head_ok && let Err(e) = baby.set_default_branch(&desired).await {
167 tracing::warn!(
168 repo_id = %repo.id,
169 branch = %desired,
170 error = %e,
171 "failed to update on-disk HEAD symbolic-ref"
172 );
173 }
174
175 Ok(())
176}
Deleted lib/git/src/sync/commits.rs
@@ -1,207 +0,0 @@
1use std::collections::{HashMap, HashSet};
2
3use export::AppDatabase;
4use gitbaby::GitBaby;
5use gitbaby::commit::types::ListCommitsOptions;
6use models::git::git_commits;
7use models::users::{user_emails, user_external_identity};
8use sea_orm::{ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter, QuerySelect, Set};
9use time::OffsetDateTime;
10use uuid::Uuid;
11
12use crate::sync::error::{SyncError, SyncResult};
13
14const BATCH_SIZE: usize = 100;
15
16#[derive(Debug, Clone)]
17pub(crate) struct CommitData {
18 pub sha: String,
19 pub author_name: String,
20 pub author_email: String,
21 pub committer_name: String,
22 pub committer_email: String,
23 pub message: String,
24 pub authored_at: OffsetDateTime,
25 pub committed_at: OffsetDateTime,
26}
27
28pub(crate) async fn collect_new_commits(
29 baby: &GitBaby,
30 existing_shas: &HashSet<String>,
31 max_total: usize,
32) -> SyncResult<Vec<CommitData>> {
33 let branches = baby
34 .list_branches(Default::default())
35 .await
36 .map_err(SyncError::Git)?;
37
38 let mut seen: HashSet<String> = existing_shas.clone();
39 let mut out: Vec<CommitData> = Vec::new();
40
41 for branch in branches {
42 if out.len() >= max_total {
43 tracing::debug!(
44 new_commits = out.len(),
45 max_total,
46 "commit collection hit max_commits_per_sync cap"
47 );
48 break;
49 }
50 let remaining = max_total - out.len();
51 let opts = ListCommitsOptions {
52 revision: branch.name.clone(),
53 max_count: Some(remaining as u32),
54 ..Default::default()
55 };
56 let commits = match baby.list_commits(opts).await {
57 Ok(v) => v,
58 Err(e) => {
59 tracing::warn!(branch = %branch.name, error = %e, "list_commits failed; skip branch");
60 continue;
61 }
62 };
63 for info in commits {
64 let sha = info.id.to_string();
65 if !seen.insert(sha.clone()) {
66 continue;
67 }
68 out.push(CommitData {
69 sha,
70 author_name: info.author.name,
71 author_email: info.author.email,
72 committer_name: info.committer.name,
73 committer_email: info.committer.email,
74 message: info.message,
75 authored_at: info.author.time,
76 committed_at: info.committer.time,
77 });
78 if out.len() >= max_total {
79 break;
80 }
81 }
82 }
83
84 Ok(out)
85}
86
87pub(crate) async fn existing_commit_shas(
88 db: &AppDatabase,
89 repo_id: Uuid,
90) -> SyncResult<HashSet<String>> {
91 let rows = git_commits::Entity::find()
92 .filter(git_commits::Column::RepoId.eq(repo_id))
93 .select_only()
94 .column(git_commits::Column::Sha)
95 .into_tuple::<String>()
96 .all(db.read())
97 .await
98 .map_err(SyncError::Db)?;
99 Ok(rows.into_iter().collect())
100}
101
102pub(crate) async fn collect_new_commits_for_sync(
103 baby: &GitBaby,
104 db: &AppDatabase,
105 repo_id: Uuid,
106 max_total: usize,
107) -> SyncResult<Vec<CommitData>> {
108 let existing = existing_commit_shas(db, repo_id).await?;
109 collect_new_commits(baby, &existing, max_total).await
110}
111
112pub(crate) async fn sync_commits(
113 txn: &DatabaseTransaction,
114 repo_id: Uuid,
115 new_commits: &[CommitData],
116) -> SyncResult<usize> {
117 if new_commits.is_empty() {
118 return Ok(0);
119 }
120
121 let author_map = resolve_user_ids(txn, new_commits).await?;
122
123 let now = OffsetDateTime::now_utc();
124 for chunk in new_commits.chunks(BATCH_SIZE) {
125 let models = chunk
126 .iter()
127 .map(|c| {
128 let lower = c.author_email.to_lowercase();
129 git_commits::ActiveModel {
130 repo_id: Set(repo_id),
131 sha: Set(c.sha.clone()),
132 message: Set(c.message.clone()),
133 author_id: Set(author_map.get(&lower).copied()),
134 author_name: Set(c.author_name.clone()),
135 author_email: Set(c.author_email.clone()),
136 authored_at: Set(c.authored_at),
137 committer_name: Set(c.committer_name.clone()),
138 committer_email: Set(c.committer_email.clone()),
139 committed_at: Set(c.committed_at),
140 created_at: Set(now),
141 }
142 })
143 .collect::<Vec<_>>();
144 let mut oc = sea_orm::sea_query::OnConflict::new();
145 oc.do_nothing();
146 git_commits::Entity::insert_many(models)
147 .on_conflict(oc)
148 .exec(txn)
149 .await
150 .map_err(SyncError::Db)?;
151 }
152
153 Ok(new_commits.len())
154}
155
156async fn resolve_user_ids(
157 txn: &DatabaseTransaction,
158 commits: &[CommitData],
159) -> SyncResult<HashMap<String, i64>> {
160 let mut lower_emails: HashSet<String> = HashSet::new();
161 for c in commits {
162 lower_emails.insert(c.author_email.to_lowercase());
163 lower_emails.insert(c.committer_email.to_lowercase());
164 }
165 if lower_emails.is_empty() {
166 return Ok(HashMap::new());
167 }
168
169 let rows = user_emails::Entity::find()
170 .filter(
171 user_emails::Column::LowerEmail.is_in(lower_emails.iter().cloned().collect::<Vec<_>>()),
172 )
173 .all(txn)
174 .await
175 .map_err(SyncError::Db)?;
176
177 let mut map: HashMap<String, i64> = rows
178 .into_iter()
179 .map(|r| (r.lower_email, r.user_id))
180 .collect();
181
182 let unmatched: Vec<String> = lower_emails
183 .iter()
184 .filter(|email| !map.contains_key(*email))
185 .cloned()
186 .collect();
187 if !unmatched.is_empty() {
188 let identities = user_external_identity::Entity::find()
189 .filter(user_external_identity::Column::UserId.is_not_null())
190 .filter(user_external_identity::Column::Email.is_in(unmatched))
191 .all(txn)
192 .await
193 .map_err(SyncError::Db)?;
194 for row in identities {
195 if let (Some(email), Some(uid)) = (row.email, row.user_id) {
196 map.entry(email.to_lowercase()).or_insert(uid);
197 }
198 }
199 }
200
201 tracing::debug!(
202 emails = lower_emails.len(),
203 matched = map.len(),
204 "resolved commit author emails to users"
205 );
206 Ok(map)
207}
Modified lib/git/src/sync/error.rs View lib/git/src/sync/error.rs
@@ -1,6 +1,6 @@
11use std::io;
22
3use gitbaby::error::BabyError;
3use crate::rpcgit::error::BabyError;
44use redis::RedisError;
55use thiserror::Error;
66
Deleted lib/git/src/sync/fsck.rs
@@ -1,50 +0,0 @@
1use std::path::Path;
2
3use crate::service::gitcmd::{run_git, run_git_soft};
4
5pub async fn run_gc(repo_dir: &Path, timeout_secs: u64) {
6 let dir_str = repo_dir.display().to_string();
7 match run_git(
8 "sync::gc",
9 repo_dir,
10 vec![
11 "gc".to_string(),
12 "--prune=now".to_string(),
13 "--quiet".to_string(),
14 ],
15 &[],
16 timeout_secs,
17 )
18 .await
19 {
20 Ok(_) => {}
21 Err(e) => tracing::warn!(repo_dir = %dir_str, error = %e, "git gc failed"),
22 }
23}
24
25pub async fn run_fsck(repo_dir: &Path, timeout_secs: u64) {
26 let dir_str = repo_dir.display().to_string();
27 match run_git_soft(
28 "sync::fsck",
29 repo_dir,
30 vec!["fsck".to_string(), "--no-progress".to_string()],
31 &[],
32 timeout_secs,
33 )
34 .await
35 {
36 Ok(out) => {
37 let stdout = String::from_utf8_lossy(&out.stdout);
38 if stdout.contains("error") || stdout.contains("broken") {
39 tracing::warn!(
40 repo_dir = %dir_str,
41 output = %stdout.trim(),
42 "fsck reported integrity issues"
43 );
44 }
45 }
46 Err(e) => {
47 tracing::warn!(repo_dir = %dir_str, error = %e, "fsck failed");
48 }
49 }
50}
Deleted lib/git/src/sync/languages.rs
@@ -1,212 +0,0 @@
1use std::time::Duration;
2
3use gitbaby::GitBaby;
4use gitbaby::commit::types::TreeEntryMode;
5use gitbaby::tree::types::WalkTreeOptions;
6use models::git::git_languages;
7use models::repo::repos;
8use sea_orm::{
9 ActiveModelTrait, ColumnTrait, DatabaseTransaction, EntityTrait, IntoActiveModel, QueryFilter,
10 Set,
11};
12use time::OffsetDateTime;
13use uuid::Uuid;
14
15use crate::service::analysis::ext_to_language;
16use crate::sync::error::{SyncError, SyncResult};
17use config::git::SyncSettings;
18
19pub async fn sync_repo_languages(
20 baby: &GitBaby,
21 txn: &DatabaseTransaction,
22 repo: &repos::Model,
23 settings: &SyncSettings,
24) -> SyncResult<()> {
25 let desired = match detect_languages(baby, repo, settings).await {
26 Ok(Some(langs)) => langs,
27 Ok(None) => return Ok(()),
28 Err(e) => {
29 tracing::warn!(repo_id = %repo.id, error = %e, "language detection failed; skipping");
30 return Ok(());
31 }
32 };
33
34 let existing = git_languages::Entity::find()
35 .filter(git_languages::Column::RepoId.eq(repo.id))
36 .all(txn)
37 .await
38 .map_err(SyncError::Db)?;
39
40 let mut existing_by_lang: std::collections::HashMap<String, git_languages::Model> = existing
41 .into_iter()
42 .map(|r| (r.language.clone(), r))
43 .collect();
44 let mut stale: std::collections::HashSet<String> = existing_by_lang.keys().cloned().collect();
45
46 let now = OffsetDateTime::now_utc();
47 for entry in &desired {
48 stale.remove(&entry.language);
49 let bp = entry.percentage_bp;
50 let size = entry.size;
51 match existing_by_lang.get(&entry.language) {
52 Some(row) if row.percentage_bp != bp || row.size != size => {
53 let mut am = row.clone().into_active_model();
54 am.percentage_bp = Set(bp);
55 am.size = Set(size);
56 am.update(txn).await.map_err(SyncError::Db)?;
57 }
58 Some(_) => {}
59 None => {
60 git_languages::ActiveModel {
61 id: Set(Uuid::now_v7()),
62 repo_id: Set(repo.id),
63 language: Set(entry.language.clone()),
64 percentage_bp: Set(bp),
65 size: Set(size),
66 created_at: Set(now),
67 }
68 .insert(txn)
69 .await
70 .map_err(SyncError::Db)?;
71 }
72 }
73 }
74
75 for lang in stale {
76 let Some(row) = existing_by_lang.remove(&lang) else {
77 continue;
78 };
79 git_languages::Entity::delete_by_id(row.id)
80 .exec(txn)
81 .await
82 .map_err(SyncError::Db)?;
83 }
84
85 if let Some(primary) = desired.first()
86 && repo.primary_language.as_deref() != Some(primary.language.as_str())
87 {
88 tracing::debug!(
89 repo_id = %repo.id,
90 old = ?repo.primary_language,
91 new = %primary.language,
92 "primary language changed during sync"
93 );
94 let mut am = repo.clone().into_active_model();
95 am.primary_language = Set(Some(primary.language.clone()));
96 am.update(txn).await.map_err(SyncError::Db)?;
97 }
98
99 Ok(())
100}
101
102#[derive(Debug, Clone)]
103struct LanguageEntry {
104 language: String,
105 percentage_bp: i32,
106 size: i64,
107}
108
109async fn detect_languages(
110 baby: &GitBaby,
111 repo: &repos::Model,
112 settings: &SyncSettings,
113) -> SyncResult<Option<Vec<LanguageEntry>>> {
114 let Some(revision) = resolve_language_revision(baby, repo).await else {
115 tracing::debug!(repo_id = %repo.id, "no resolvable revision; skipping language detection");
116 return Ok(None);
117 };
118 let timeout = Duration::from_secs(settings.language_timeout_secs);
119 let Ok(stats) =
120 tokio::time::timeout(timeout, walk_lang_stats(baby.clone(), revision.clone())).await
121 else {
122 tracing::warn!(revision = %revision, "language detection timed out");
123 return Ok(None);
124 };
125
126 let entries: Vec<(String, i64)> = match stats {
127 Ok(map) => map.into_iter().collect(),
128 Err(e) => return Err(e),
129 };
130
131 let mut entries = entries;
132 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
133 entries.truncate(settings.max_languages);
134 let total: i64 = entries.iter().map(|(_, s)| *s).sum();
135 let out: Vec<LanguageEntry> = entries
136 .into_iter()
137 .filter(|(_, size)| *size > 0)
138 .map(|(language, size)| {
139 let bp = if total > 0 {
140 (((size as f64) / (total as f64)) * 10_000.0).round() as i32
141 } else {
142 0
143 };
144 LanguageEntry {
145 language,
146 percentage_bp: bp.clamp(0, 10_000),
147 size,
148 }
149 })
150 .collect();
151
152 tracing::debug!(
153 repo_id = %repo.id,
154 revision = %revision,
155 languages = out.len(),
156 "language detection finished"
157 );
158 Ok(Some(out))
159}
160
161async fn resolve_language_revision(baby: &GitBaby, repo: &repos::Model) -> Option<String> {
162 let mut candidates: Vec<String> = Vec::new();
163 candidates.push("HEAD".to_string());
164 let db = repo.default_branch.trim();
165 if !db.is_empty() {
166 let head = format!("refs/heads/{db}");
167 if !candidates.contains(&head) {
168 candidates.push(head);
169 }
170 }
171 for cand in &candidates {
172 match baby.rev_parse(cand).await {
173 Ok(_) => return Some(cand.clone()),
174 Err(gitbaby::error::BabyError::RevisionNotFound(_)) => continue,
175 Err(_) => continue,
176 }
177 }
178 match baby
179 .list_branches(gitbaby::branch::ListBranchesOptions::default())
180 .await
181 {
182 Ok(branches) => branches.first().map(|b| b.sha.to_string()),
183 Err(_) => None,
184 }
185}
186
187async fn walk_lang_stats(
188 baby: GitBaby,
189 revision: String,
190) -> SyncResult<std::collections::HashMap<String, i64>> {
191 let entries = baby
192 .walk_tree(
193 &revision,
194 std::path::Path::new(""),
195 WalkTreeOptions {
196 recurse: true,
197 show_trees: false,
198 },
199 )
200 .await
201 .map_err(SyncError::Git)?;
202
203 let mut map: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
204 for e in entries {
205 if (e.mode == TreeEntryMode::Blob || e.mode == TreeEntryMode::Exec)
206 && let Some(lang) = ext_to_language(&e.name)
207 {
208 *map.entry(lang.to_string()).or_default() += e.size.unwrap_or(0) as i64;
209 }
210 }
211 Ok(map)
212}
Deleted lib/git/src/sync/license.rs
@@ -1,168 +0,0 @@
1use std::path::Path;
2
3use models::repo::repo_license;
4use models::repo::repos;
5use sea_orm::{ActiveModelTrait, DatabaseTransaction, EntityTrait, Set};
6
7use crate::sync::error::{SyncError, SyncResult};
8
9const LICENSE_FILES: &[&str] = &[
10 "LICENSE",
11 "LICENSE.md",
12 "LICENSE.txt",
13 "COPYING",
14 "COPYING.md",
15];
16
17pub async fn sync_repo_license(
18 txn: &DatabaseTransaction,
19 repo_dir: &Path,
20 repo: &repos::Model,
21) -> SyncResult<()> {
22 let Some(name) = repo.license_name.clone() else {
23 return Ok(());
24 };
25 let body = read_license_body(repo_dir).await?;
26 tracing::debug!(
27 repo_id = %repo.id,
28 license = %name,
29 body_found = body.is_some(),
30 "upserting license record"
31 );
32 let display_name = infer_display_name(&name, body.as_deref());
33 let description = infer_description(&name, body.as_deref());
34 let url = infer_url(&name);
35 let now = time::OffsetDateTime::now_utc();
36 if let Some(existing) = repo_license::Entity::find_by_id(name.clone())
37 .one(txn)
38 .await?
39 {
40 let mut am: repo_license::ActiveModel = existing.into();
41 am.display_name = Set(display_name);
42 am.description = Set(description);
43 if let Some(text) = body.clone() {
44 am.body = Set(text);
45 }
46 am.url = Set(url);
47 am.updated_at = Set(now);
48 am.update(txn).await?;
49 } else {
50 let am = repo_license::ActiveModel {
51 name: Set(name.clone()),
52 display_name: Set(display_name),
53 description: Set(description),
54 body: Set(body.unwrap_or_else(|| format!("License: {name}"))),
55 url: Set(url),
56 created_at: Set(now),
57 updated_at: Set(now),
58 };
59 am.insert(txn).await?;
60 }
61 let verified = repo_license::Entity::find_by_id(name.clone())
62 .one(txn)
63 .await?
64 .ok_or_else(|| {
65 SyncError::Internal(format!(
66 "license `{name}` missing after upsert for repo {}",
67 repo.id
68 ))
69 })?;
70 if verified.name != name {
71 return Err(SyncError::InvalidLicense(format!(
72 "license row mismatch: expected `{name}`, got `{}`",
73 verified.name
74 )));
75 }
76 Ok(())
77}
78
79async fn read_license_body(repo_dir: &Path) -> SyncResult<Option<String>> {
80 for candidate in LICENSE_FILES {
81 let path = repo_dir.join(candidate);
82 match tokio::fs::read(&path).await {
83 Ok(bytes) => {
84 let text = String::from_utf8_lossy(&bytes).to_string();
85 if !text.trim().is_empty() {
86 return Ok(Some(truncate(&text, 64 * 1024)));
87 }
88 }
89 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
90 Err(error) => {
91 tracing::warn!(path = %path.display(), error = %error, "failed to read license file");
92 return Err(SyncError::Io(error));
93 }
94 }
95 }
96 Ok(None)
97}
98
99fn truncate(text: &str, limit: usize) -> String {
100 if text.len() <= limit {
101 return text.to_string();
102 }
103 let mut end = limit;
104 while end > 0 && !text.is_char_boundary(end) {
105 end -= 1;
106 }
107 text[..end].to_string()
108}
109
110fn infer_display_name(name: &str, body: Option<&str>) -> String {
111 if let Some(text) = body {
112 for line in text.lines().take(20) {
113 let trimmed = line.trim();
114 if trimmed.is_empty() {
115 continue;
116 }
117 if trimmed.to_lowercase().contains("license") {
118 let candidate = trimmed
119 .trim_start_matches(|c: char| !c.is_alphanumeric())
120 .trim();
121 if candidate.len() >= 3 && candidate.len() <= 80 {
122 return candidate.to_string();
123 }
124 }
125 }
126 }
127 match name.to_uppercase().as_str() {
128 "MIT" => "MIT License",
129 "APACHE-2.0" | "APACHE2" => "Apache License 2.0",
130 "GPL-3.0" | "GPL3" => "GNU General Public License v3.0",
131 "BSD-3-CLAUSE" => "BSD 3-Clause License",
132 "BSD-2-CLAUSE" => "BSD 2-Clause License",
133 "MPL-2.0" => "Mozilla Public License 2.0",
134 "ISC" => "ISC License",
135 "UNLICENSE" => "The Unlicense",
136 other => other,
137 }
138 .to_string()
139}
140
141fn infer_description(name: &str, body: Option<&str>) -> Option<String> {
142 let needle = name.to_lowercase();
143 if let Some(text) = body {
144 for line in text.lines().take(20) {
145 let lower = line.to_lowercase();
146 if lower.contains(&needle) && lower.contains("license") && line.len() <= 200 {
147 return Some(line.trim().to_string());
148 }
149 }
150 }
151 None
152}
153
154fn infer_url(name: &str) -> Option<String> {
155 let key = name.to_uppercase();
156 let slug = match key.as_str() {
157 "MIT" => "mit",
158 "APACHE-2.0" | "APACHE2" => "apache-2.0",
159 "GPL-3.0" | "GPL3" => "gpl-3.0",
160 "GPL-2.0" | "GPL2" => "gpl-2.0",
161 "BSD-3-CLAUSE" | "BSD-2-CLAUSE" => "bsd",
162 "MPL-2.0" => "mpl-2.0",
163 "ISC" => "isc",
164 "UNLICENSE" => "unlicense",
165 _ => return None,
166 };
167 Some(format!("https://spdx.org/licenses/{slug}.html"))
168}
Deleted lib/git/src/sync/lock.rs
@@ -1,105 +0,0 @@
1use std::process;
2
3use export::{AppRedis, RedisConnection};
4use uuid::Uuid;
5
6use crate::sync::error::SyncResult;
7
8const LOCK_KEY_PREFIX: &str = "git:sync:lock:";
9
10const REFRESH_SCRIPT: &str = "local v = redis.call('GET', KEYS[1])\nif v == ARGV[1] then\n redis.call('PEXPIRE', KEYS[1], ARGV[2])\n return 1\nend\nreturn 0";
11
12const RELEASE_SCRIPT: &str = "local v = redis.call('GET', KEYS[1])\nif v == ARGV[1] then\n return redis.call('DEL', KEYS[1])\nend\nreturn 0";
13
14macro_rules! dispatch {
15 ($conn:expr, |$c:ident| $body:expr) => {
16 match &mut $conn {
17 RedisConnection::Standalone($c) => $body,
18 RedisConnection::Cluster($c) => $body,
19 }
20 };
21}
22
23pub struct SyncLock {
24 redis: AppRedis,
25 key: String,
26 value: String,
27}
28
29impl SyncLock {
30 #[must_use]
31 pub fn value(&self) -> &str {
32 &self.value
33 }
34
35 #[must_use]
36 pub fn key(&self) -> &str {
37 &self.key
38 }
39
40 pub async fn refresh(&self, ttl_secs: u64) -> SyncResult<bool> {
41 let mut conn = self.redis.connection_clone();
42 let n: i64 = dispatch!(conn, |c| {
43 redis::cmd("EVAL")
44 .arg(REFRESH_SCRIPT)
45 .arg(1)
46 .arg(&self.key)
47 .arg(&self.value)
48 .arg(ttl_secs.saturating_mul(1000))
49 .query_async(c)
50 .await
51 })?;
52 if n != 1 {
53 tracing::warn!(key = %self.key, "sync lock heartbeat refresh lost ownership");
54 }
55 Ok(n == 1)
56 }
57
58 pub async fn release(&self) -> SyncResult<()> {
59 let mut conn = self.redis.connection_clone();
60 let _: i64 = dispatch!(conn, |c| {
61 redis::cmd("EVAL")
62 .arg(RELEASE_SCRIPT)
63 .arg(1)
64 .arg(&self.key)
65 .arg(&self.value)
66 .query_async(c)
67 .await
68 })?;
69 tracing::debug!(key = %self.key, "sync lock released");
70 Ok(())
71 }
72}
73
74pub async fn acquire_lock(
75 redis: &AppRedis,
76 repo_id: Uuid,
77 ttl_secs: u64,
78) -> SyncResult<Option<SyncLock>> {
79 let key = format!("{LOCK_KEY_PREFIX}{repo_id}");
80 let value = format!("{}:{}", Uuid::now_v7(), process::id());
81
82 let mut conn = redis.connection_clone();
83 let acquired: bool = dispatch!(conn, |c| {
84 redis::cmd("SET")
85 .arg(&key)
86 .arg(&value)
87 .arg("NX")
88 .arg("EX")
89 .arg(ttl_secs)
90 .query_async(c)
91 .await
92 })?;
93
94 if !acquired {
95 tracing::debug!(key = %key, repo_id = %repo_id, "sync lock busy; another sync in progress");
96 return Ok(None);
97 }
98
99 tracing::debug!(key = %key, repo_id = %repo_id, ttl_secs, "sync lock acquired");
100 Ok(Some(SyncLock {
101 redis: redis.clone(),
102 key,
103 value,
104 }))
105}
Modified lib/git/src/sync/mod.rs View lib/git/src/sync/mod.rs
@@ -1,32 +1,7 @@
1pub mod branches;
2pub mod commits;
31pub mod error;
42pub mod event;
5pub mod fsck;
6pub mod languages;
7pub mod license;
8pub mod lock;
9pub mod post_receive;
10pub mod pulls;
113pub mod queue;
12pub mod stats;
13#[allow(clippy::module_inception)]
14pub mod sync;
15pub mod tags;
16pub mod webhook;
17pub mod worker;
184
195pub use error::{SyncError, SyncResult};
20pub use event::{
21 EnqueueError as SyncEnqueueError, PostReceiveRefUpdate, PostReceiveTask, enqueue_post_receive,
22 enqueue_post_receive_dlq,
23};
24pub use lock::{SyncLock, acquire_lock};
25pub use post_receive::{
26 PushTarget, enqueue_post_receive_push, enqueue_post_receive_push_with_ci_logged,
27 prepare_post_receive,
28};
6pub use event::{EnqueueError as SyncEnqueueError, PostReceiveRefUpdate, PostReceiveTask};
297pub use queue::SyncQueue;
30pub use sync::RepoSync;
31pub use webhook::{NoopWebhookTx, RepoWebhookTx, WebhookTx};
32pub use worker::{RunningWorker, SyncWorker};
Deleted lib/git/src/sync/post_receive.rs
@@ -1,261 +0,0 @@
1use std::fs;
2use std::io;
3#[cfg(unix)]
4use std::os::unix::fs::PermissionsExt;
5use std::path::{Path, PathBuf};
6
7use uuid::Uuid;
8
9use crate::sync::event::{PostReceiveRefUpdate, PostReceiveTask, enqueue_post_receive};
10use crate::sync::queue::SyncQueue;
11use config::git::SyncSettings;
12
13/// Who and where a push belongs to. Grouped into a struct because every
14/// post-receive entry point threads the same four values, and three of them are
15/// `Uuid`-shaped — positional arguments silently swap space and repo.
16pub struct PushTarget<'a> {
17 pub space_id: Uuid,
18 pub repo_id: Uuid,
19 pub actor_id: Option<i64>,
20 pub bare: &'a Path,
21}
22
23pub const POST_RECEIVE_HOOK: &str = "post-receive";
24pub const CAPTURE_FILE: &str = "_gitdata_post_receive";
25
26const HEX_OID_LEN: usize = 40;
27const HEX_OID_SHA256_LEN: usize = 64;
28
29pub fn hook_path(bare: &Path) -> PathBuf {
30 bare.join("hooks").join(POST_RECEIVE_HOOK)
31}
32
33pub fn capture_path(bare: &Path) -> PathBuf {
34 bare.join(CAPTURE_FILE)
35}
36
37pub fn ensure_post_receive_hook(bare: &Path) -> io::Result<()> {
38 let path = hook_path(bare);
39 let script = capture_script();
40 if path.exists() {
41 let existing = fs::read_to_string(&path)?;
42 if !existing.contains(CAPTURE_FILE) {
43 tracing::warn!(
44 path = %path.display(),
45 "existing post-receive hook will not be overwritten; post-receive sync may not capture pushes"
46 );
47 return Ok(());
48 }
49 if existing == script {
50 return Ok(());
51 }
52 fs::write(&path, script)?;
53 #[cfg(unix)]
54 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
55 tracing::debug!(path = %path.display(), "refreshed post-receive capture hook");
56 return Ok(());
57 }
58 let Some(hooks_dir) = path.parent() else {
59 return Err(io::Error::other("hook path has no parent"));
60 };
61 fs::create_dir_all(hooks_dir)?;
62 fs::write(&path, script)?;
63 #[cfg(unix)]
64 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
65 tracing::debug!(path = %path.display(), "installed post-receive capture hook");
66 Ok(())
67}
68
69fn capture_script() -> String {
70 format!(
71 "#!/bin/sh\n# gitdata post-receive capture hook\nwhile read old_oid new_oid refname; do\n printf '%s %s %s\\n' \"$old_oid\" \"$new_oid\" \"$refname\" >> \"$GIT_DIR/{CAPTURE_FILE}\"\ndone\n"
72 )
73}
74
75pub fn clear_post_receive_capture(bare: &Path) -> io::Result<()> {
76 match fs::remove_file(capture_path(bare)) {
77 Ok(()) => Ok(()),
78 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
79 Err(e) => Err(e),
80 }
81}
82
83pub fn prepare_post_receive(bare: &Path) -> io::Result<()> {
84 ensure_post_receive_hook(bare)?;
85 clear_post_receive_capture(bare)
86}
87
88pub fn take_post_receive_capture(bare: &Path) -> Vec<PostReceiveRefUpdate> {
89 let path = capture_path(bare);
90 let content = match fs::read_to_string(&path) {
91 Ok(c) => c,
92 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
93 tracing::warn!(
94 bare = %bare.display(),
95 "post-receive capture file missing after push; capture hook may not have run"
96 );
97 return Vec::new();
98 }
99 Err(e) => {
100 tracing::warn!(error = %e, "failed to read post-receive capture file");
101 return Vec::new();
102 }
103 };
104 if let Err(e) = fs::remove_file(&path) {
105 tracing::warn!(error = %e, "failed to remove post-receive capture file");
106 }
107 let mut dropped = 0usize;
108 let updates: Vec<_> = content
109 .lines()
110 .filter_map(|line| {
111 let parsed = parse_capture_line(line);
112 if parsed.is_none() {
113 dropped += 1;
114 }
115 parsed
116 })
117 .collect();
118 if dropped > 0 {
119 tracing::warn!(
120 bare = %bare.display(),
121 dropped,
122 "discarded unparseable post-receive capture lines"
123 );
124 }
125 updates
126}
127
128fn parse_capture_line(line: &str) -> Option<PostReceiveRefUpdate> {
129 let mut parts = line.split_whitespace();
130 let old_oid = parts.next()?;
131 let new_oid = parts.next()?;
132 let name = parts.next()?;
133 if !is_valid_oid(old_oid) || !is_valid_oid(new_oid) || name.is_empty() {
134 return None;
135 }
136 Some(PostReceiveRefUpdate::new(name, old_oid, new_oid))
137}
138
139fn is_valid_oid(oid: &str) -> bool {
140 let len = oid.len();
141 if len != HEX_OID_LEN && len != HEX_OID_SHA256_LEN {
142 return false;
143 }
144 oid.bytes().all(|b| b.is_ascii_hexdigit())
145}
146
147pub async fn enqueue_post_receive_push(
148 queue: &SyncQueue,
149 settings: &SyncSettings,
150 target: &PushTarget<'_>,
151) -> Result<usize, EnqueueError> {
152 enqueue_push_impl(queue, settings, None, target).await
153}
154
155/// Enqueues the sync task and (optionally) publishes a CI push event from the
156/// same capture file read.
157pub async fn enqueue_post_receive_push_with_ci(
158 queue: &SyncQueue,
159 settings: &SyncSettings,
160 ci_topic: Option<&str>,
161 target: &PushTarget<'_>,
162) -> Result<usize, EnqueueError> {
163 enqueue_push_impl(queue, settings, ci_topic, target).await
164}
165
166async fn enqueue_push_impl(
167 queue: &SyncQueue,
168 settings: &SyncSettings,
169 ci_topic: Option<&str>,
170 target: &PushTarget<'_>,
171) -> Result<usize, EnqueueError> {
172 // Copied out so the borrow of `target` ends here; the fields are `Copy`.
173 let (channel_id, repo_id, actor_id) = (target.space_id, target.repo_id, target.actor_id);
174 let bare = target.bare;
175 let refs = take_post_receive_capture(bare);
176 if refs.is_empty() {
177 return Ok(0);
178 }
179 if let Some(topic) = ci_topic {
180 let ci_refs: Vec<ci_protocol::events::RefUpdate> = refs
181 .iter()
182 .map(|r| ci_protocol::events::RefUpdate {
183 name: r.name.clone(),
184 old_oid: r.old_oid.clone(),
185 new_oid: r.new_oid.clone(),
186 })
187 .collect();
188 if ci_refs.iter().any(|r| !r.is_delete()) {
189 let event = ci_protocol::events::CiEvent::Push(ci_protocol::events::PushEvent {
190 id: Uuid::now_v7(),
191 repo_id,
192 space_id: channel_id,
193 actor_id,
194 refs: ci_refs,
195 trace: export::telemetry::traceparent(),
196 request_id: export::telemetry::request_id(),
197 });
198 match serde_json::to_vec(&event) {
199 Ok(payload) => {
200 if let Err(error) = queue.publish_raw(topic, None, &payload).await {
201 tracing::warn!(%error, "ci push event publish failed");
202 }
203 }
204 Err(error) => tracing::warn!(%error, "ci push event serialize failed"),
205 }
206 }
207 }
208 let task = PostReceiveTask::new(repo_id, channel_id, actor_id, refs);
209 enqueue_post_receive(queue, settings, &task).await?;
210 Ok(task.refs.len())
211}
212
213pub async fn enqueue_post_receive_push_logged(
214 queue: &SyncQueue,
215 settings: &SyncSettings,
216 target: &PushTarget<'_>,
217) {
218 let (space_id, repo_id) = (target.space_id, target.repo_id);
219 match enqueue_post_receive_push(queue, settings, target).await {
220 Ok(n) if n > 0 => {
221 tracing::debug!(
222 count = n,
223 space_id = %space_id,
224 repo_id = %repo_id,
225 "enqueued post-receive ref updates"
226 )
227 }
228 Ok(_) => tracing::debug!(space_id = %space_id, repo_id = %repo_id, "no refs to enqueue"),
229 Err(e) => tracing::warn!(
230 error = %e,
231 space_id = %space_id,
232 repo_id = %repo_id,
233 "post-receive enqueue failed"
234 ),
235 }
236}
237
238pub async fn enqueue_post_receive_push_with_ci_logged(
239 queue: &SyncQueue,
240 settings: &SyncSettings,
241 ci_topic: Option<&str>,
242 target: &PushTarget<'_>,
243) {
244 let (space_id, repo_id) = (target.space_id, target.repo_id);
245 match enqueue_post_receive_push_with_ci(queue, settings, ci_topic, target).await {
246 Ok(n) if n > 0 => tracing::debug!(
247 count = n,
248 space_id = %space_id,
249 repo_id = %repo_id,
250 "enqueued post-receive ref updates and ci event"
251 ),
252 Ok(_) => tracing::debug!(space_id = %space_id, repo_id = %repo_id, "no refs to enqueue"),
253 Err(e) => tracing::warn!(
254 error = %e,
255 space_id = %space_id,
256 repo_id = %repo_id,
257 "post-receive enqueue failed"
258 ),
259 }
260}
261pub use crate::sync::event::EnqueueError;
Deleted lib/git/src/sync/pulls.rs
@@ -1,123 +0,0 @@
1use models::pull_request::{pull_request_event_line, pull_requests};
2use models::repo::repos;
3use sea_orm::{
4 ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter,
5};
6use time::OffsetDateTime;
7
8use crate::sync::error::SyncResult;
9use crate::sync::event::PostReceiveTask;
10use export::AppDatabase;
11
12pub const EVENT_HEAD_UPDATED: &str = "head_updated";
13pub const EVENT_HEAD_DELETED: &str = "head_deleted";
14pub const EVENT_BASE_UPDATED: &str = "base_updated";
15pub const ZERO_OID_LEN: usize = 40;
16
17/// push 之后把分支最新提交同步到受影响的开放合并请求。
18pub async fn refresh_pulls_after_sync(
19 db: &AppDatabase,
20 repo: &repos::Model,
21 task: &PostReceiveTask,
22) -> SyncResult<u64> {
23 let mut touched = 0u64;
24 for update in &task.refs {
25 let Some(branch) = update.name.strip_prefix("refs/heads/") else {
26 continue;
27 };
28 let deleted = update.new_oid.chars().all(|c| c == '0');
29 if let Err(error) =
30 refresh_for_branch(db, repo, branch, &update.new_oid, deleted, task.actor_id).await
31 {
32 tracing::warn!(
33 error = %error,
34 repo_id = %repo.id,
35 branch,
36 "pull request sync skipped"
37 );
38 } else {
39 touched += 1;
40 }
41 }
42 if touched > 0 {
43 recount_open_pulls(db, repo.id).await?;
44 }
45 Ok(touched)
46}
47
48async fn refresh_for_branch(
49 db: &AppDatabase,
50 repo: &repos::Model,
51 branch: &str,
52 new_oid: &str,
53 deleted: bool,
54 actor: Option<i64>,
55) -> SyncResult<()> {
56 let open = pull_requests::Entity::find()
57 .filter(pull_requests::Column::RepoId.eq(repo.id))
58 .filter(pull_requests::Column::HasMerged.eq(false))
59 .filter(pull_requests::Column::ClosedAt.is_null())
60 .filter(pull_requests::Column::HeadRepoId.is_null())
61 .all(db.read())
62 .await?;
63
64 for pull in open {
65 if pull.head_branch == branch {
66 if deleted {
67 record_event(db, &pull, actor, EVENT_HEAD_DELETED, None).await?;
68 continue;
69 }
70 if pull.head_sha != new_oid {
71 let mut model: pull_requests::ActiveModel = pull.clone().into();
72 model.head_sha = Set(new_oid.to_string());
73 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
74 model.update(db.write()).await?;
75 record_event(db, &pull, actor, EVENT_HEAD_UPDATED, Some(new_oid)).await?;
76 }
77 } else if pull.base_branch == branch && !deleted && pull.base_sha != new_oid {
78 let mut model: pull_requests::ActiveModel = pull.clone().into();
79 model.base_sha = Set(new_oid.to_string());
80 model.update(db.write()).await?;
81 record_event(db, &pull, actor, EVENT_BASE_UPDATED, Some(new_oid)).await?;
82 }
83 }
84 Ok(())
85}
86
87async fn record_event(
88 db: &AppDatabase,
89 pull: &pull_requests::Model,
90 actor: Option<i64>,
91 event_type: &str,
92 commit_sha: Option<&str>,
93) -> SyncResult<()> {
94 let row = pull_request_event_line::ActiveModel {
95 id: Set(uuid::Uuid::now_v7()),
96 pull_request_id: Set(pull.id),
97 user_id: Set(actor.unwrap_or(pull.author_id)),
98 event_type: Set(event_type.to_string()),
99 commit_sha: Set(commit_sha.map(|sha| sha.to_string())),
100 content: Set(None),
101 created_at: Set(OffsetDateTime::now_utc()),
102 };
103 row.insert(db.write()).await?;
104 Ok(())
105}
106
107/// 重新统计开放合并请求数量,写回 `repos.nums_pr`。
108pub async fn recount_open_pulls(db: &AppDatabase, repo_id: uuid::Uuid) -> SyncResult<()> {
109 let open = pull_requests::Entity::find()
110 .filter(pull_requests::Column::RepoId.eq(repo_id))
111 .filter(pull_requests::Column::HasMerged.eq(false))
112 .filter(pull_requests::Column::ClosedAt.is_null())
113 .count(db.read())
114 .await?;
115 let model = repos::ActiveModel {
116 id: Set(repo_id),
117 nums_pr: Set(i32::try_from(open).unwrap_or(i32::MAX)),
118 // A partial update leaves every other column `NotSet`.
119 ..Default::default()
120 };
121 model.update(db.write()).await?;
122 Ok(())
123}
Deleted lib/git/src/sync/stats.rs
@@ -1,52 +0,0 @@
1use models::repo::repos;
2use sea_orm::{ActiveModelTrait, DatabaseTransaction, EntityTrait, IntoActiveModel, Set};
3
4use crate::service::repos::{count_commits, dir_size};
5use crate::sync::error::{SyncError, SyncResult};
6
7pub async fn sync_repo_stats(
8 txn: &DatabaseTransaction,
9 repo: &repos::Model,
10 repo_dir: &std::path::Path,
11 new_commits: usize,
12 branch_count: usize,
13 tag_count: usize,
14 command_timeout_secs: u64,
15) -> SyncResult<()> {
16 let git_size = dir_size(repo_dir)
17 .await
18 .map_err(|e| SyncError::Internal(format!("dir_size: {e}")))? as i64;
19 let total_commits = count_commits(repo_dir, command_timeout_secs)
20 .await
21 .map_err(|e| SyncError::Internal(format!("count_commits: {e}")))?
22 as i32;
23
24 let fresh = repos::Entity::find_by_id(repo.id)
25 .one(txn)
26 .await
27 .map_err(SyncError::Db)?
28 .ok_or_else(|| SyncError::NotFound(format!("repo {}", repo.id)))?;
29
30 let now = time::OffsetDateTime::now_utc();
31 let mut am = fresh.clone().into_active_model();
32 am.git_size = Set(git_size);
33 am.nums_commits = Set(total_commits);
34 am.nums_branches = Set(branch_count as i32);
35 am.nums_tags = Set(tag_count as i32);
36 if new_commits > 0 {
37 am.last_pushed_at = Set(Some(now));
38 }
39 am.updated_at = Set(now);
40 am.update(txn).await.map_err(SyncError::Db)?;
41
42 tracing::debug!(
43 repo_id = %repo.id,
44 git_size,
45 total_commits,
46 branches = branch_count,
47 tags = tag_count,
48 new_commits,
49 "repo stats updated"
50 );
51 Ok(())
52}
Deleted lib/git/src/sync/sync.rs
@@ -1,206 +0,0 @@
1use std::path::PathBuf;
2use std::sync::Arc;
3
4use cache::CacheClient;
5use config::git::SyncSettings;
6use export::AppDatabase;
7use gitbaby::GitBaby;
8use models::repo::repos;
9use sea_orm::{DatabaseTransaction, TransactionTrait};
10use tokio::sync::watch;
11use uuid::Uuid;
12
13use crate::context::facade::GitContext;
14use crate::sync::branches;
15use crate::sync::commits::{CommitData, collect_new_commits_for_sync, sync_commits};
16use crate::sync::error::{SyncError, SyncResult};
17use crate::sync::fsck;
18use crate::sync::languages;
19use crate::sync::license;
20use crate::sync::lock::{self, SyncLock};
21use crate::sync::stats;
22use crate::sync::tags;
23
24struct HeartbeatGuard {
25 handle: tokio::task::JoinHandle<()>,
26}
27
28impl Drop for HeartbeatGuard {
29 fn drop(&mut self) {
30 if !self.handle.is_finished() {
31 self.handle.abort();
32 }
33 }
34}
35
36#[derive(Clone)]
37pub struct RepoSync {
38 db: Arc<AppDatabase>,
39 repo: repos::Model,
40 baby: GitBaby,
41 redis: export::AppRedis,
42 repo_root: PathBuf,
43 settings: SyncSettings,
44}
45
46impl RepoSync {
47 pub fn new(
48 db: Arc<AppDatabase>,
49 redis: export::AppRedis,
50 cache: Arc<CacheClient>,
51 repo: repos::Model,
52 repo_root: PathBuf,
53 settings: SyncSettings,
54 ) -> Self {
55 let ctx = GitContext {
56 model: repo.clone(),
57 cache: cache.clone(),
58 database: db.clone(),
59 repo_root: repo_root.clone(),
60 objects_root: repo_root.clone(),
61 };
62 Self {
63 db,
64 repo,
65 baby: GitBaby::new(Arc::new(ctx)),
66 redis,
67 repo_root,
68 settings,
69 }
70 }
71
72 #[must_use]
73 pub fn repo_model(&self) -> &repos::Model {
74 &self.repo
75 }
76
77 #[must_use]
78 pub fn repo_id(&self) -> Uuid {
79 self.repo.id
80 }
81
82 #[must_use]
83 pub fn repo_dir(&self) -> PathBuf {
84 crate::context::facade::repo_dir_for(&self.repo_root, self.repo.id)
85 }
86
87 pub async fn sync(&self) -> SyncResult<()> {
88 let ttl = self.settings.lock_ttl_secs;
89 let lock = match lock::acquire_lock(&self.redis, self.repo.id, ttl).await? {
90 Some(l) => Arc::new(l),
91 None => return Err(SyncError::Locked),
92 };
93
94 let (tx, mut rx) = watch::channel(false);
95 let hb_lock = lock.clone();
96 let heartbeat = tokio::spawn(async move {
97 let interval = (ttl / 3).max(1);
98 loop {
99 tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
100 match hb_lock.refresh(ttl).await {
101 Ok(true) => {}
102 Ok(false) | Err(_) => {
103 let _ = tx.send(true);
104 break;
105 }
106 }
107 }
108 });
109 let heartbeat = HeartbeatGuard { handle: heartbeat };
110
111 let result = tokio::select! {
112 r = self.sync_work() => r,
113 _ = rx.changed() => Err(SyncError::Locked),
114 };
115
116 let is_locked_failure = matches!(result.as_ref(), Err(SyncError::Locked));
117 if !is_locked_failure {
118 let _ = lock.release().await;
119 }
120 drop(heartbeat);
121
122 result
123 }
124
125 pub async fn sync_work(&self) -> SyncResult<()> {
126 let baby = self.baby.clone();
127 let new_commits: Vec<CommitData> = collect_new_commits_for_sync(
128 &baby,
129 &self.db,
130 self.repo.id,
131 self.settings.max_commits_per_sync,
132 )
133 .await?;
134 tracing::debug!(
135 repo_id = %self.repo.id,
136 new_commits = new_commits.len(),
137 "collected new commits for sync"
138 );
139
140 let txn: DatabaseTransaction = self.db.write().begin().await.map_err(SyncError::Db)?;
141
142 let (branch_count, _synced_commits, tag_count) =
143 self.sync_refs_and_data(&txn, &new_commits).await?;
144 tracing::debug!(
145 repo_id = %self.repo.id,
146 branches = branch_count,
147 tags = tag_count,
148 "refs synced"
149 );
150
151 languages::sync_repo_languages(&baby, &txn, &self.repo, &self.settings).await?;
152 tracing::debug!(repo_id = %self.repo.id, "languages synced");
153
154 stats::sync_repo_stats(
155 &txn,
156 &self.repo,
157 &self.repo_dir(),
158 new_commits.len(),
159 branch_count,
160 tag_count,
161 self.settings.git_command_timeout_secs,
162 )
163 .await?;
164 tracing::debug!(repo_id = %self.repo.id, "stats synced");
165
166 license::sync_repo_license(&txn, &self.repo_dir(), &self.repo).await?;
167 tracing::debug!(repo_id = %self.repo.id, "license synced");
168
169 search::outbox::enqueue_repo(&txn, self.repo.id)
170 .await
171 .map_err(SyncError::Db)?;
172
173 txn.commit().await.map_err(SyncError::Db)?;
174 tracing::debug!(repo_id = %self.repo.id, "sync transaction committed");
175
176 let gc_timeout = self.settings.git_command_timeout_secs.max(1800);
177 tracing::debug!(repo_id = %self.repo.id, "starting fsck/gc");
178 fsck::run_gc(&self.repo_dir(), gc_timeout).await;
179 fsck::run_fsck(&self.repo_dir(), gc_timeout).await;
180
181 Ok(())
182 }
183
184 async fn sync_refs_and_data(
185 &self,
186 txn: &DatabaseTransaction,
187 new_commits: &[CommitData],
188 ) -> SyncResult<(usize, usize, usize)> {
189 let baby = self.baby.clone();
190 let branch_count = branches::sync_branches(&baby, txn, &self.repo).await?;
191 let synced = sync_commits(txn, self.repo.id, new_commits).await?;
192 let tag_count = tags::sync_tags(&baby, txn, &self.repo).await?;
193 Ok((branch_count, synced, tag_count))
194 }
195
196 pub async fn set_default_branch(&self, name: &str) -> SyncResult<()> {
197 self.baby
198 .set_default_branch(name)
199 .await
200 .map_err(SyncError::from)
201 }
202
203 pub async fn force_release_lock(lock: &SyncLock) -> SyncResult<()> {
204 lock.release().await
205 }
206}
Deleted lib/git/src/sync/tags.rs
@@ -1,158 +0,0 @@
1use std::collections::HashMap;
2
3use gitbaby::GitBaby;
4use gitbaby::tags::types::{ListTagsOptions, TagType};
5use models::git::git_tags;
6use models::git::git_tags::TagType as DbTagType;
7use models::repo::repos;
8use sea_orm::{
9 ActiveModelTrait, ColumnTrait, DatabaseTransaction, EntityTrait, IntoActiveModel, QueryFilter,
10 Set,
11};
12use time::OffsetDateTime;
13use uuid::Uuid;
14
15use crate::sync::error::{SyncError, SyncResult};
16
17const MAX_TAGS: u64 = 10_000;
18
19#[derive(Debug, Clone)]
20pub(crate) struct TagTip {
21 pub name: String,
22 pub target_sha: String,
23 pub tag_type: DbTagType,
24 pub message: Option<String>,
25 pub tagger_name: Option<String>,
26 pub tagger_email: Option<String>,
27}
28
29pub async fn sync_tags(
30 baby: &GitBaby,
31 txn: &DatabaseTransaction,
32 repo: &repos::Model,
33) -> SyncResult<usize> {
34 let collected = collect_tag_tips(baby).await?;
35
36 let existing = git_tags::Entity::find()
37 .filter(git_tags::Column::RepoId.eq(repo.id))
38 .all(txn)
39 .await
40 .map_err(SyncError::Db)?;
41
42 let mut existing_by_name: HashMap<String, git_tags::Model> =
43 existing.into_iter().map(|t| (t.name.clone(), t)).collect();
44
45 tracing::debug!(
46 repo_id = %repo.id,
47 tips = collected.len(),
48 db_tags = existing_by_name.len(),
49 "syncing tags"
50 );
51
52 let mut stale: std::collections::HashSet<String> = existing_by_name.keys().cloned().collect();
53 let (mut updated, mut inserted) = (0usize, 0usize);
54
55 for tip in &collected {
56 stale.remove(&tip.name);
57 match existing_by_name.get(&tip.name) {
58 Some(row)
59 if row.commit_sha != tip.target_sha
60 || row.message != tip.message
61 || row.tag_type != tip.tag_type =>
62 {
63 let mut am = row.clone().into_active_model();
64 am.commit_sha = Set(tip.target_sha.clone());
65 am.tag_type = Set(tip.tag_type);
66 am.message = Set(tip.message.clone());
67 am.tagger_name = Set(tip.tagger_name.clone());
68 am.tagger_email = Set(tip.tagger_email.clone());
69 am.update(txn).await.map_err(SyncError::Db)?;
70 updated += 1;
71 }
72 Some(_) => {}
73 None => {
74 git_tags::ActiveModel {
75 id: Set(Uuid::now_v7()),
76 repo_id: Set(repo.id),
77 name: Set(tip.name.clone()),
78 tag_type: Set(tip.tag_type),
79 commit_sha: Set(tip.target_sha.clone()),
80 message: Set(tip.message.clone()),
81 tagger_name: Set(tip.tagger_name.clone()),
82 tagger_email: Set(tip.tagger_email.clone()),
83 created_at: Set(OffsetDateTime::now_utc()),
84 }
85 .insert(txn)
86 .await
87 .map_err(SyncError::Db)?;
88 inserted += 1;
89 }
90 }
91 }
92
93 let mut deleted = 0usize;
94 for name in stale {
95 let Some(row) = existing_by_name.remove(&name) else {
96 continue;
97 };
98 git_tags::Entity::delete_by_id(row.id)
99 .exec(txn)
100 .await
101 .map_err(SyncError::Db)?;
102 deleted += 1;
103 }
104 tracing::debug!(
105 repo_id = %repo.id,
106 updated,
107 inserted,
108 deleted,
109 "tag sync wrote changes"
110 );
111
112 Ok(collected.len())
113}
114
115pub(crate) async fn collect_tag_tips(baby: &GitBaby) -> SyncResult<Vec<TagTip>> {
116 let refs = baby
117 .list_tags(ListTagsOptions::default())
118 .await
119 .map_err(SyncError::Git)?;
120 let mut tips = Vec::with_capacity(refs.len().min(MAX_TAGS as usize));
121
122 for info in refs.into_iter().take(MAX_TAGS as usize) {
123 let tag_type = match info.tag_type {
124 TagType::Annotated => DbTagType::Annotated,
125 TagType::Lightweight => DbTagType::Lightweight,
126 };
127 let name = info.name;
128 let mut tip = TagTip {
129 name: name.clone(),
130 target_sha: info.target_sha.to_string(),
131 tag_type,
132 message: info.message,
133 tagger_name: None,
134 tagger_email: None,
135 };
136 if matches!(tag_type, DbTagType::Annotated) {
137 match baby.find_tag(&name).await {
138 Ok(full) => {
139 tip.message = full.message;
140 if let Some(sig) = full.tagger {
141 tip.tagger_name = Some(sig.name);
142 tip.tagger_email = Some(sig.email);
143 }
144 }
145 Err(e) => {
146 tracing::warn!(
147 tag = %tip.name,
148 error = %e,
149 "annotated tag enrichment failed; storing without message/tagger"
150 );
151 }
152 }
153 }
154 tips.push(tip);
155 }
156
157 Ok(tips)
158}
Deleted lib/git/src/sync/webhook.rs
@@ -1,158 +0,0 @@
1use std::error::Error;
2use std::sync::Arc;
3
4use export::AppDatabase;
5use models::repo::repos;
6use models::space::spaces;
7use models::webhook::repo_webhook_delivery::{self, RepoWebhookDeliveryStatus};
8use models::webhook::repo_webhooks;
9use models::webhook::webhooks::WebhookStatus;
10use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
11use time::OffsetDateTime;
12use uuid::Uuid;
13
14use crate::sync::event::{PostReceiveRefUpdate, PostReceiveTask};
15
16const PUSH_EVENT: &str = "push";
17
18#[async_trait::async_trait]
19pub trait WebhookTx: Send + Sync {
20 async fn dispatch(&self, task: &PostReceiveTask) -> Result<(), Box<dyn Error + Send + Sync>>;
21}
22
23#[derive(Debug, Default, Clone)]
24pub struct NoopWebhookTx;
25
26#[async_trait::async_trait]
27impl WebhookTx for NoopWebhookTx {
28 async fn dispatch(&self, task: &PostReceiveTask) -> Result<(), Box<dyn Error + Send + Sync>> {
29 tracing::warn!(
30 task_id = %task.id,
31 repo_id = %task.repo_id,
32 refs = task.refs.len(),
33 "webhook dispatch not configured; message dropped (NoopWebhookTx)"
34 );
35 Ok(())
36 }
37}
38
39#[derive(Clone)]
40pub struct RepoWebhookTx {
41 db: Arc<AppDatabase>,
42}
43
44impl RepoWebhookTx {
45 #[must_use]
46 pub fn new(db: Arc<AppDatabase>) -> Self {
47 Self { db }
48 }
49}
50
51#[async_trait::async_trait]
52impl WebhookTx for RepoWebhookTx {
53 async fn dispatch(&self, task: &PostReceiveTask) -> Result<(), Box<dyn Error + Send + Sync>> {
54 if task.refs.is_empty() {
55 return Ok(());
56 }
57 let hooks = repo_webhooks::Entity::find()
58 .filter(repo_webhooks::Column::RepoId.eq(task.repo_id))
59 .filter(repo_webhooks::Column::Status.eq(WebhookStatus::Active))
60 .all(self.db.read())
61 .await?;
62 if hooks.is_empty() {
63 return Ok(());
64 }
65 let Some(repo) = repos::Entity::find_by_id(task.repo_id)
66 .one(self.db.read())
67 .await?
68 else {
69 tracing::warn!(repo_id = %task.repo_id, "repo missing for webhook dispatch");
70 return Ok(());
71 };
72 let space = spaces::Entity::find_by_id(task.channel_id)
73 .one(self.db.read())
74 .await?;
75 let full_name = match &space {
76 Some(space) => format!("{}/{}", space.lower_name, repo.name),
77 None => repo.name.clone(),
78 };
79 let now = OffsetDateTime::now_utc();
80 let mut enqueued = 0u64;
81 for hook in hooks {
82 if !subscribes_push(&hook.events) {
83 continue;
84 }
85 for update in &task.refs {
86 let payload = push_payload(task, &repo, &full_name, update);
87 repo_webhook_delivery::ActiveModel {
88 id: Set(Uuid::now_v7()),
89 webhook_id: Set(hook.id),
90 repo_id: Set(task.repo_id),
91 event_type: Set(PUSH_EVENT.to_string()),
92 payload: Set(payload),
93 status: Set(RepoWebhookDeliveryStatus::Pending),
94 attempts: Set(0),
95 next_retry_at: Set(now),
96 response_status: Set(None),
97 response_body: Set(None),
98 last_error: Set(None),
99 created_at: Set(now),
100 updated_at: Set(now),
101 }
102 .insert(self.db.write())
103 .await?;
104 enqueued += 1;
105 }
106 }
107 if enqueued > 0 {
108 tracing::debug!(
109 task_id = %task.id,
110 repo_id = %task.repo_id,
111 deliveries = enqueued,
112 "push webhook deliveries enqueued"
113 );
114 }
115 Ok(())
116 }
117}
118
119fn subscribes_push(events: &serde_json::Value) -> bool {
120 let events: Vec<String> = serde_json::from_value(events.clone()).unwrap_or_default();
121 events
122 .iter()
123 .any(|event| event == PUSH_EVENT || event == "*")
124}
125
126fn push_payload(
127 task: &PostReceiveTask,
128 repo: &repos::Model,
129 full_name: &str,
130 update: &PostReceiveRefUpdate,
131) -> serde_json::Value {
132 let ref_type = if update.name.starts_with("refs/tags/") {
133 "tag"
134 } else if update.name.starts_with("refs/heads/") {
135 "branch"
136 } else {
137 "other"
138 };
139 let actor = task.actor_id.map(|id| id.to_string());
140 serde_json::json!({
141 "ref": update.name,
142 "before": update.old_oid,
143 "after": update.new_oid,
144 "created": update.is_create(),
145 "deleted": update.is_delete(),
146 "ref_type": ref_type,
147 "pusher": { "id": actor },
148 "sender": { "id": actor },
149 "repository": {
150 "id": repo.id,
151 "name": repo.name,
152 "full_name": full_name,
153 "private": repo.is_private,
154 "default_branch": repo.default_branch,
155 "space_id": task.channel_id,
156 },
157 })
158}
Deleted lib/git/src/sync/worker.rs
@@ -1,365 +0,0 @@
1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::time::Duration;
4
5use cache::CacheClient;
6use config::git::SyncSettings;
7use export::{AppDatabase, AppRedis};
8use models::repo::repos;
9use sea_orm::EntityTrait;
10use tokio::task::JoinHandle;
11use tokio_util::sync::CancellationToken;
12use tracing::{Instrument as _, debug, error, info, warn};
13
14use crate::sync::error::{SyncError, SyncResult};
15use crate::sync::event::PostReceiveTask;
16use crate::sync::queue::SyncQueue;
17use crate::sync::sync::RepoSync;
18use crate::sync::webhook::WebhookTx;
19
20const ATTEMPT_KEY_PREFIX: &str = "git:sync:attempts:";
21const ATTEMPT_TTL_SECS: u64 = 3600;
22const REQUEUE_BACKOFF_SECS: u64 = 5;
23
24#[derive(Clone)]
25pub struct SyncWorker {
26 db: Arc<AppDatabase>,
27 redis: AppRedis,
28 cache: Arc<CacheClient>,
29 repo_root: PathBuf,
30 queue: SyncQueue,
31 webhook: Arc<dyn WebhookTx>,
32 settings: SyncSettings,
33 cancel: CancellationToken,
34}
35
36pub struct RunningWorker {
37 handles: Vec<JoinHandle<()>>,
38}
39
40impl RunningWorker {
41 pub async fn shutdown(self) -> SyncResult<()> {
42 for h in self.handles {
43 if !h.is_finished() {
44 h.abort();
45 let _ = h.await;
46 }
47 }
48 Ok(())
49 }
50}
51
52impl SyncWorker {
53 #[allow(clippy::too_many_arguments)]
54 pub fn new(
55 db: Arc<AppDatabase>,
56 redis: AppRedis,
57 cache: Arc<CacheClient>,
58 repo_root: PathBuf,
59 queue: SyncQueue,
60 webhook: Arc<dyn WebhookTx>,
61 settings: SyncSettings,
62 cancel: CancellationToken,
63 ) -> Self {
64 let concurrency = settings.concurrency.max(1);
65 info!(
66 concurrency,
67 topic = %settings.queue_topic,
68 "starting sync worker"
69 );
70 Self {
71 db,
72 redis,
73 cache,
74 repo_root,
75 queue,
76 webhook,
77 settings,
78 cancel,
79 }
80 }
81
82 pub async fn run(self) -> SyncResult<RunningWorker> {
83 let concurrency = self.settings.concurrency.max(1);
84 let topic = self.settings.queue_topic.clone();
85 let brpop = self.settings.brpop_timeout_secs;
86
87 let mut queue = self.queue;
88 if queue.consumer_conns().is_empty() {
89 queue = SyncQueue::with_consumer_conns(queue.redis().clone(), concurrency).await?;
90 }
91
92 let mut handles = Vec::with_capacity(concurrency);
93 for i in 0..concurrency {
94 let db = self.db.clone();
95 let redis = self.redis.clone();
96 let cache = self.cache.clone();
97 let repo_root = self.repo_root.clone();
98 let queue = queue.clone();
99 let webhook = self.webhook.clone();
100 let settings = self.settings.clone();
101 let cancel = self.cancel.clone();
102 let topic = topic.clone();
103
104 handles.push(tokio::spawn(async move {
105 run_consumer(
106 i, &db, &redis, &cache, &repo_root, &queue, webhook, &settings, &cancel,
107 &topic, brpop,
108 )
109 .await;
110 }));
111 }
112 Ok(RunningWorker { handles })
113 }
114}
115
116#[allow(clippy::too_many_arguments)]
117async fn run_consumer(
118 id: usize,
119 db: &Arc<AppDatabase>,
120 redis: &AppRedis,
121 cache: &Arc<CacheClient>,
122 repo_root: &Path,
123 queue: &SyncQueue,
124 webhook: Arc<dyn WebhookTx>,
125 settings: &SyncSettings,
126 cancel: &CancellationToken,
127 topic: &str,
128 brpop_timeout: u64,
129) {
130 debug!(consumer = id, "consumer started");
131 loop {
132 if cancel.is_cancelled() {
133 break;
134 }
135 let payload = tokio::select! {
136 biased;
137 _ = cancel.cancelled() => break,
138 res = queue.consume(topic, brpop_timeout, id) => match res {
139 Ok(Some(p)) => p,
140 Ok(None) => continue,
141 Err(e) => {
142 warn!(consumer = id, error = %e, "queue consume error; backing off");
143 tokio::time::sleep(Duration::from_secs(REQUEUE_BACKOFF_SECS)).await;
144 continue;
145 }
146 }
147 };
148
149 let task: PostReceiveTask = match serde_json::from_slice(&payload) {
150 Ok(t) => t,
151 Err(e) => {
152 error!(
153 consumer = id,
154 error = %e,
155 payload_len = payload.len(),
156 "invalid sync task payload; dropping"
157 );
158 continue;
159 }
160 };
161
162 let started = std::time::Instant::now();
163 debug!(
164 consumer = id,
165 task_id = %task.id,
166 repo_id = %task.repo_id,
167 refs = task.refs.len(),
168 "sync task received"
169 );
170 let span = tracing::info_span!(
171 "git.sync",
172 task_id = %task.id,
173 repo_id = %task.repo_id,
174 refs = task.refs.len(),
175 request_id = tracing::field::Empty
176 );
177 if let Some(request_id) = task.request_id.as_deref() {
178 span.record("request_id", request_id);
179 }
180 export::telemetry::set_parent_from_traceparent(&span, task.trace.as_deref());
181
182 let repo = match repos::Entity::find_by_id(task.repo_id).one(db.read()).await {
183 Ok(Some(r)) => r,
184 Ok(None) => {
185 warn!(
186 consumer = id,
187 task_id = %task.id,
188 repo_id = %task.repo_id,
189 "repo not found; skipping"
190 );
191 continue;
192 }
193 Err(e) => {
194 error!(
195 consumer = id,
196 task_id = %task.id,
197 repo_id = %task.repo_id,
198 error = %e,
199 "repo lookup failed; skipping"
200 );
201 continue;
202 }
203 };
204
205 let rsync = RepoSync::new(
206 db.clone(),
207 redis.clone(),
208 cache.clone(),
209 repo,
210 repo_root.to_path_buf(),
211 settings.clone(),
212 );
213
214 match export::middleware::with_request_id(
215 task.request_id.as_deref(),
216 rsync.sync().instrument(span.clone()),
217 )
218 .await
219 {
220 Ok(()) => {
221 export::metrics::sync_job("ok");
222 info!(
223 consumer = id,
224 task_id = %task.id,
225 repo_id = %task.repo_id,
226 elapsed_ms = started.elapsed().as_millis() as u64,
227 "sync task completed"
228 );
229 if let Err(e) =
230 crate::sync::pulls::refresh_pulls_after_sync(db, rsync.repo_model(), &task)
231 .await
232 {
233 warn!(
234 consumer = id,
235 task_id = %task.id,
236 repo_id = %task.repo_id,
237 error = %e,
238 "pull request refresh failed"
239 );
240 }
241 if let Err(e) = export::middleware::with_request_id(
242 task.request_id.as_deref(),
243 webhook.dispatch(&task).instrument(span.clone()),
244 )
245 .await
246 {
247 warn!(
248 consumer = id,
249 task_id = %task.id,
250 repo_id = %task.repo_id,
251 error = %e,
252 "webhook dispatch failed"
253 );
254 }
255 }
256 Err(e) if e.is_locked() => {
257 export::metrics::sync_job("locked");
258 warn!(
259 consumer = id,
260 task_id = %task.id,
261 repo_id = %task.repo_id,
262 "sync locked; requeueing"
263 );
264 if let Err(e2) = requeue_or_dlq(queue, settings, &task, &payload).await {
265 error!(
266 consumer = id,
267 task_id = %task.id,
268 repo_id = %task.repo_id,
269 error = %e2,
270 "requeue failed"
271 );
272 }
273 }
274 Err(e) => {
275 export::metrics::sync_job("error");
276 error!(
277 consumer = id,
278 task_id = %task.id,
279 repo_id = %task.repo_id,
280 error = %e,
281 "sync task failed"
282 );
283 if let Err(e2) = requeue_or_dlq(queue, settings, &task, &payload).await {
284 error!(
285 consumer = id,
286 task_id = %task.id,
287 repo_id = %task.repo_id,
288 error = %e2,
289 "requeue failed"
290 );
291 }
292 }
293 }
294 }
295 debug!(consumer = id, "consumer stopped");
296}
297
298async fn requeue_or_dlq(
299 queue: &SyncQueue,
300 settings: &SyncSettings,
301 task: &PostReceiveTask,
302 payload: &[u8],
303) -> SyncResult<()> {
304 let key = format!("{ATTEMPT_KEY_PREFIX}{}", task.id);
305 let attempts = queue.incr_with_ttl(&key, ATTEMPT_TTL_SECS).await?;
306
307 if attempts as u64 > settings.max_lock_retries {
308 let _ = queue.delete(&key).await;
309 queue
310 .publish_raw(&settings.dlq_topic, Some(task.repo_id.as_bytes()), payload)
311 .await?;
312 warn!(
313 task_id = %task.id,
314 repo_id = %task.repo_id,
315 attempts,
316 "task exceeded retry limit; sent to DLQ"
317 );
318 return Ok(());
319 }
320
321 tokio::time::sleep(Duration::from_secs(REQUEUE_BACKOFF_SECS)).await;
322 queue
323 .publish_raw(
324 &settings.queue_topic,
325 Some(task.repo_id.as_bytes()),
326 payload,
327 )
328 .await?;
329 debug!(
330 task_id = %task.id,
331 repo_id = %task.repo_id,
332 attempts,
333 "re-queued retry task"
334 );
335 Ok(())
336}
337
338pub async fn run_once(
339 worker: &SyncWorker,
340 task: PostReceiveTask,
341 payload: Vec<u8>,
342) -> SyncResult<()> {
343 let repo = repos::Entity::find_by_id(task.repo_id)
344 .one(worker.db.read())
345 .await
346 .map_err(SyncError::Db)?
347 .ok_or_else(|| SyncError::NotFound(format!("repo {}", task.repo_id)))?;
348
349 let rsync = RepoSync::new(
350 worker.db.clone(),
351 worker.redis.clone(),
352 worker.cache.clone(),
353 repo,
354 worker.repo_root.clone(),
355 worker.settings.clone(),
356 );
357
358 let result = rsync.sync().await;
359 if result.is_ok() {
360 let _ = worker.webhook.dispatch(&task).await;
361 } else if let Err(e) = requeue_or_dlq(&worker.queue, &worker.settings, &task, &payload).await {
362 warn!(error = %e, "requeue failed in run_once");
363 }
364 result
365}
Modified lib/git/src/errors.rs View lib/git/src/errors.rs
@@ -42,6 +42,7 @@
4242 GitError::Forbidden => Self::Forbidden,
4343 GitError::Conflict(msg) => Self::Conflict(msg),
4444 GitError::InvalidArgument(msg) => Self::BadRequest(msg),
45 GitError::Unimplemented(msg) => Self::Internal(msg.to_string()),
4546 GitError::Cache(_) | GitError::Git(_) | GitError::Internal(_) => {
4647 tracing::warn!(error = %error, "git handler failed");
4748 Self::Internal("internal server error".to_string())
Modified lib/git/src/lib.rs View lib/git/src/lib.rs
@@ -1,12 +1,10 @@
11pub mod context;
22pub mod errors;
3pub mod graphql;
43pub mod handler;
54pub mod lfs;
65pub mod openapi;
76pub mod routes;
7pub mod rpcgit;
88pub mod service;
99pub mod share;
10pub mod smart;
11pub mod ssh;
1210pub mod sync;
Modified lib/git/src/openapi.rs View lib/git/src/openapi.rs
@@ -6,8 +6,6 @@
66#[openapi(
77 info(title = "gitdata git api", version = "1.0"),
88 paths(
9 handler::graphql::graphql_get,
10 handler::graphql::graphql_post,
119 handler::code_comments::list_code_comments::list_code_comments,
1210 handler::code_comments::create_code_comment::create_code_comment,
1311 handler::code_comments::modify::update_code_comment,
Modified lib/git/src/routes.rs View lib/git/src/routes.rs
@@ -39,8 +39,6 @@
3939use crate::handler::diffs::get_diff::get_diff;
4040use crate::handler::diffs::range_diff::range_diff;
4141use crate::handler::diffs::raw::diff_raw;
42use crate::handler::graphql::graphql_get;
43use crate::handler::graphql::graphql_post;
4442use crate::handler::hooks::execute_script::execute_script;
4543use crate::handler::lfs::cleanup_unreferenced::cleanup_unreferenced;
4644use crate::handler::lfs::list_locks::list_locks;
@@ -103,25 +101,20 @@
103101 remove_branch_whitelist, remove_tag_whitelist,
104102};
105103use crate::service::GitService;
104use axum::Router;
106105use axum::routing::{delete, get, post, put};
107use axum::{Extension, Router};
108106
109pub fn router(service: GitService) -> Router<GitService> {
110 let schema = crate::graphql::build_schema(service.state.clone());
107pub fn router(_service: GitService) -> Router<GitService> {
111108 Router::new()
112 .route("/graphql", post(graphql_post).get(graphql_get))
113109 .nest("/repos", repos_router())
114110 .nest("/topics", topics_router())
115111 .nest("/licenses", licenses_router())
116 .layer(Extension(schema))
117112}
118113
119pub fn internal_router(service: GitService) -> Router<GitService> {
120 let schema = crate::graphql::build_schema(service.state.clone());
114pub fn internal_router(_service: GitService) -> Router<GitService> {
121115 Router::new()
122116 .nest("/repos", repos_internal_router())
123117 .merge(licenses_internal_router())
124 .layer(Extension(schema))
125118}
126119
127120fn repos_router() -> Router<GitService> {
Modified lib/git/Cargo.toml View lib/git/Cargo.toml
@@ -4,7 +4,9 @@
44edition.workspace = true
55
66[dependencies]
7gitbaby = { workspace = true }
7kenshell = { workspace = true }
8tonic = { workspace = true }
9prost-types = { workspace = true }
810ci-protocol = { workspace = true }
911export = { workspace = true }
1012config = { workspace = true }
@@ -15,13 +17,11 @@
1517serde_json = { workspace = true }
1618thiserror = { workspace = true }
1719async-trait = { workspace = true }
18gix = { workspace = true }
1920sea-orm = { workspace = true }
2021tokio = { workspace = true, features = ["fs", "process", "time", "io-util", "sync", "rt", "macros"] }
2122uuid = { workspace = true }
2223time = { workspace = true }
2324globset = { workspace = true }
24async-graphql = { workspace = true }
2525base64 = { workspace = true }
2626sha2 = { workspace = true }
2727axum = { workspace = true }
@@ -30,7 +30,6 @@
3030tokio-util = { workspace = true }
3131redis = { workspace = true }
3232tracing = { workspace = true }
33russh = { workspace = true }
3433flate2 = { workspace = true }
3534rand = { workspace = true }
3635bytes = { workspace = true }
Added lib/git/todo.md View lib/git/todo.md
@@ -0,0 +1,168 @@
1# lib/git → KenShell gRPC 迁移说明与待办
2
3## 0. 当前状态(已完成)
4
5- 新增 crate `lib/kenshell`:`proto/kenshell/**` 与 `proto/gitken/v1/{lint,shared,cleanup,conflicts}.proto`
6 从 `KenShell/proto` 原样复制;`build.rs` 只生成 client;`KenShellClient` 暴露全部
7 `kenshell.v1` 与 `kenshell.internal.v1` 服务客户端,并统一 64 MiB 消息上限与 `x-request-id`。
8- 配置写在 `[git]` 段:`kenshell_endpoint`、`kenshell_rpc_key`、`kenshell_timeout_secs`。
9- `lib/git` 不再包含任何 `gix`、不再 spawn `git`:
10 - 删除 `lib/gitbaby`(整个 crate)与 workspace 依赖 `gix`、`russh`、`ssh-key`、`sha1`。
11 - 删除 `src/smart/**`、`src/ssh/**`、`src/graphql/**`、`src/share/git_env.rs`、
12 `src/service/gitcmd.rs`、`src/lfs/{object,handle}.rs`。
13 - `src/sync/` 只保留 Redis 队列与事件类型(`queue`/`event`/`error`),worker 删除。
14- `GitService` 持有 `KenShellClient`;`GitContext` 由「本地路径 + gix 仓库」改为
15 「`RepositoryRef` + KenShell 客户端」。
16- `src/rpcgit/` 是 KenShell 的薄封装:保留原 `gitbaby` 的公开数据类型与 `GitBaby`
17 方法签名,内部全部映射到 gRPC;业务层(service/handler)改用 `crate::rpcgit::...`。
18- 已切到 RPC 的能力:tree/file 列表与解析、blob 元数据/内容/LFS 指针、commit 列表/详情/
19 统计/blame/rev-parse/is-ancestor、diff(raw/range/shortstat/numstat/changed-paths/
20 compare/diff-tree)、refs/branches/tags 读写、conflict 检查与明细、analysis 读、
21 archive 创建与查询、remote 增删查与 import/fetch/push(异步 Job 轮询)、
22 repository 存在性/体积、commit status 回写、code comment 的 revision 解析。
23- `app/githost` 退役(smart HTTP/SSH 由 KenShell 提供);`charts` 与 `scripts/dev.*`
24 移除 githost、改由 `kenshell.*` 配置注入 `/git` 环境变量。
25
26## 1. 架构边界
27
28| 关注点 | 归属 |
29| --- | --- |
30| Git 对象库、引用、pack、LFS 对象、smart HTTP、SSH | KenShell |
31| 仓库/分支/标签/提交/PR/评论/星标等元数据 | KenApi 数据库 |
32| LFS 锁 | KenApi 数据库(KenShell 无锁协议) |
33| 页面渲染(maud HTML 片段、PDF 分页) | KenApi |
34| 定时任务、搜索索引、导入任务编排 | KenApi(`synchost`,见 §5) |
35
36`GitContext` 只描述「哪个仓库」:
37
38```rust
39GitContext { model, cache, database, space_name, repo_root, ks }
40```
41
42`space_name` 是组织 slug,`model.name` 是仓库名,二者组成 KenShell 的 `RepositoryRef`。
43**前提**:KenShell 必须能按 `(org slug, repo name)` 解析到自己的仓库行。
44
45## 2. KenShell 已覆盖、且已完成对接
46
47| API / 能力 | KenShell RPC |
48| --- | --- |
49| `GET /tree/{rev}`、`/html/*` 目录读取 | `TreeService/GetTree`、`ListFiles`、`ResolveTreePath` |
50| `GET /blobs/{oid}/metadata`、`/content` | `BlobService/GetBlobMetadata`、`GetBlobContent` |
51| `GET /lfs-pointers` | `BlobService/ListLfsPointers` |
52| `GET /commits`、`/commits/{rev}`、`/commits/stats`、`/commits/ancestor`、`/rev/{rev}` | `CommitService/ListCommits`、`GetCommit`、`GetCommitStats`、`IsAncestor`、`RevParse` |
53| `GET /blame` | `CommitService/ListBlame` |
54| `GET /diff`、`/diff/raw`、`/diff/for-commit`、`/diff/range` | `DiffService/CompareCommits`、`GetRawDiff`、`ListChangedPaths`、`ListDiffTree`、`GetRangeDiff`、`GetDiffShortStat`、`ListDiffNumStats` |
55| `GET /conflicts`、`/conflicts/detail` | `ConflictService/CheckConflicts`、`GetConflictDetail` |
56| `GET /analysis/{languages,contributors,code-activity}` | `AnalysisService/GetLanguages`、`ListContributors`、`GetCodeActivity` |
57| `POST /archives`、`GET /archives/{rev}`、`/archives/bundle` | `ArchiveService/StartArchive`、`GetArchiveArtifact`、`StartArchiveBundle`(Job 轮询) |
58| `GET /remotes`、`PUT/DELETE /remotes/{name}` | `RemoteService/ListRemotes`、`UpsertRemote`、`RemoveRemote` |
59| `POST /remotes/{import,fetch,push}` | `RemoteService/StartRemoteImport|StartRemoteFetch|StartRemotePush`(Job 轮询) |
60| `GET/POST /branches`、`GET/DELETE/PATCH /branches/{name}`、`{name}/default` | `RefService/*`、`CommandService/*` |
61| `GET/POST /tags`、`GET/DELETE /tags/{name}` | `RefService/ListTags|GetTag`、`CommandService/CreateTag|DeleteTag` |
62| `GET /path-log` | `CommitService/ListCommits(path=...)`(近似,见 §4) |
63| 仓库创建 | `CommandService/CreateRepository` |
64
65### 语义变化(调用方已适配)
66
671. **远端导入/拉取/推送**:由同步返回结果改为启动 Job 并轮询 `JobService/GetJob`。
68 HTTP 响应字段保持兼容(`default_branch`/`commit_count`/`updated`/`new`/`pushed`/`rejected`)。
692. **归档**:KenShell 生成产物后返回 `storage_key` + `download_url`,KenApi 不再自己打包、
70 不再写入自己的 S3。
713. **仓库创建**:只创建裸库和默认分支,**不再写入初始提交/README**(见 §4)。
724. **Tag 详情**:`TagView` 没有 tag 对象的 oid,`GitBaby` 用 `target_sha` 填充
73 `TagInfo.sha`,前端展示不受影响;若需要区分 tag 对象 oid,需 KenShell 补字段。
745. **Blame**:以 `BlameLineView` 逐行返回,KenApi 按连续相同 commit 聚合成 hunk;
75 `previous_sha`/`previous_path`/`orig_start_line` 固定为空(原实现有值)。
76
77## 3. 保留在 KenApi 的本地能力(不依赖 git 对象库)
78
79| 能力 | 说明 |
80| --- | --- |
81| LFS 锁(`/lfs/locks*`) | `git_lfs_lock` 表;KenShell 只有对象与 LFS HTTP 协议 |
82| 分支/标签保护、白名单 | KenApi 数据库;写操作时前置校验 |
83| releases / stars / watch / topics | KenApi 数据库 |
84| code comments(含 resolve/parent/outdated) | KenApi 数据库;KenShell 的 `CodeCommentService` 字段不足 |
85| HTML 片段(readme/tree/blob-pages/languages 等) | maud 渲染,数据来自 RPC |
86| PDF 分页 | `hayro`,输入来自 `BlobService` |
87| `POST /utils/patch/parse` | 纯文本解析 |
88| `POST /utils/commit-signature` | 读取用户身份,无 git |
89
90## 4. 未覆盖能力(当前返回 `501 Not Implemented`)
91
92统一错误:`GitError::Unimplemented` → HTTP 501。路由与 OpenAPI 声明保留,便于后续补齐。
93
94| 接口 / 能力 | 现状 | 需要的 KenShell 能力 |
95| --- | --- | --- |
96| `POST /pulls/{id}/merge`、`POST /pulls/{id}/update-branch`、`/gate` 的合并判定 | 不可用(`merge_plan`/`merge_execute` 直接返回 501) | `MergeService`:merge-base、merge-tree(含冲突树)、fast-forward、squash/rebase、任意 ref 更新 |
97| `GET /patch/generate` | 可用(`DiffService/GetRawDiff`) | — |
98| `POST /patch/apply` | 501 | `PatchService/ApplyPatch`(3-way) |
99| `GET /packs/stats`、`POST /packs/ingest`、`POST /packs/repack` | 501 | `PackService`(或明确由 KenShell 内部管理并删除接口) |
100| `POST /maintenance/prune`、`/fsck` | 501 | `MaintenanceService` |
101| `GET /signatures/commit/{sha}`、`/signatures/tag/{name}` | 501 | `SignatureService/VerifyCommit`、`VerifyTag` |
102| `GET /submodules`、`/submodules/resolve` | 501 | `SubmoduleService`(或解析 `.gitmodules` 的 RPC) |
103| `POST /hooks/{script}` | 501 | `HookService/Execute`(或确认废弃) |
104| `POST /contents/blobs`、`/trees`、`/commits` | 501 | `ContentWriteService`(写对象 + 建提交) |
105| `POST /lfs/cleanup` | 501 | `LfsAdminService/UnreferenceObject` 的批量版本 |
106| 仓库创建时的初始提交 / README | 不再执行 | `ContentWriteService` 或 `CommandService.CreateRepository(initial_commit)` |
107| 分支/标签删除的 hook 事件回写 | 不再触发(原 `sync` worker 负责) | KenShell 事件推送(§6) |
108| 任意 ref 写入/删除(`PATCH/DELETE /refs/{name}`、`refs/notes/*`、`refs/pull/*`) | 仅支持 `refs/heads/*` | `CommandService/UpdateRef|DeleteRef` 泛化 |
109| `GET /graphql` | **已移除**(前端未使用;字段逐步被 REST 覆盖) | 若要恢复:用 service 层重写,并补 `pack_stats`/`submodules`/`patch_generate` |
110| search 索引(`synchost` 的 search worker) | 仍按本地路径扫描,仓库已不在本地 | `SearchService` 或 `ListFiles/GetFile` 驱动索引 |
111
112## 5. 进程与部署变化
113
114- **删除** `app/githost`:smart HTTP / SSH 由 KenShell 提供。`charts/core` 移除
115 `githost` Deployment、Service、IngressRoute 与相关 values;`scripts/dev.*` 与 `justfile`
116 同步移除。
117- **保留** `app/synchost`:只承载 import worker、search worker 与健康检查;
118 `git::sync::SyncWorker` 已删除。
119- 克隆地址改为指向 KenShell:`git.http_clone_base_url`、`git.ssh_clone_*`;
120 Helm 通过 `kenshell.*` values 注入
121 `APP_GIT__KENSHELL_ENDPOINT`/`RPC_KEY`/`TIMEOUT_SECS`/`HTTP_CLONE_BASE_URL`/`SSH_CLONE_*`。
122
123## 6. 建议 KenShell 补充的协议
124
1251. `MergeService`
126 - `GetMergeBase(repository, a, b) -> oid`
127 - `MergeTrees(base, ours, theirs, opts) -> {tree_oid, conflicts[]}`
128 - `Merge(base_ref, head, strategy, message, actor) -> {method, new_sha, rewritten[]}`
129 - `UpdateRef(ref_name, new_oid, expected_old_oid, force)`
1302. `PatchService`:`GeneratePatch`、`ApplyPatch(3-way)`
1313. `SignatureService`:`VerifyCommit`、`VerifyTag`
1324. `SubmoduleService`:`ListSubmodules`、`ResolveSubmodule`
1335. `PackService`:`GetPackStats`、`IngestPack(stream)`、`Repack`
1346. `MaintenanceService`:`Prune`、`Fsck`
1357. `ContentWriteService`:`WriteBlob`、`WriteTree`、`CreateCommit`(含初始提交)
1368. `HookService`:`ExecuteHook(script, args, stdin)`
1379. `LfsLockService`(可选):`ListLocks`/`Lock`/`Unlock`/`VerifyLocks`
13810. `CommandService`:`UpdateRef`/`DeleteRef` 泛化到任意 ref
13911. `BlobService/GetBlobContent`:支持 `stream`,避免 64 MiB 单消息上限
14012. `RefService/TagView`:补 tag 对象 `sha` 与 `peeled`
14113. 事件通道:写入完成(push/command)后向 KenApi 推送事件,替代已删除的 sync worker
14214. `RepositoryService`:补齐展示字段,或明确 KenApi 为仓库元数据唯一来源
143
144## 7. 前端受影响清单
145
146来自 `web/src/shared/api/apiDefinitions.ts`(已按新 openapi 重新生成):
147
148| 前端接口 | 迁移后 |
149| --- | --- |
150| `GET /repos/{space}/{repo}/tree/{rev}`、`/blobs/*`、`/commits*`、`/blame`、`/diff*` | 正常(RPC) |
151| `GET/POST /branches*`、`/tags*`、`/remotes*` | 正常(RPC;远端操作改异步 Job,轮询在服务端完成) |
152| `GET/POST /analysis/*`、`/archives*` | 正常(创建为 Job + 轮询) |
153| `GET /html/{tree,readme,blob-content,blob-pages,languages}` | 正常 |
154| `GET /lfs/locks*`、`/lfs/locks/verify` | 正常(KenApi DB) |
155| `GET /patch/generate` | 正常(raw diff) |
156| `GET /pulls/{id}/files`、`/commits`、`/patch` | 列表与 patch 正常;**合并动作不可用** |
157| `GET /lfs-pointers` | 正常(只返回 oid/size,`file_oid` 字段置零) |
158| `GET /submodules*`、`/signatures/*`、`/path-log` | submodules/signatures 501;path-log 近似可用 |
159| `/graphql` | 已从 OpenAPI 与前端生成代码移除 |
160| 其余 DB 接口(star/watch/topics/releases/protection/whitelists/code-comments) | 正常 |
161
162## 8. 后续执行顺序
163
1641. 与 KenShell 对齐 `MergeService` + `ContentWriteService`,恢复 PR 合并与建库初始提交。
1652. 补 `SignatureService`、`SubmoduleService`、`PatchService`,替换对应 501。
1663. 补反射/事件通道,替换已删除的 sync worker(分支/标签/提交镜像与 webhook 触发)。
1674. search worker 改为 RPC 驱动。
1685. 清理 `GitContext.repo_root`、`git.repo_root`/`objects_root` 与相关 chart 挂载。
No newline at end of file
Deleted lib/gitbaby/src/archive/helper.rs
@@ -1,324 +0,0 @@
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}
Deleted lib/gitbaby/src/archive/mod.rs
@@ -1,7 +0,0 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{
6 ArchiveFormat, BundleRequest, ChildArchiveReader, CreateArchiveRequest, GetArchiveRequest,
7};
Deleted lib/gitbaby/src/archive/types.rs
@@ -1,132 +0,0 @@
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}
Deleted lib/gitbaby/src/archive/usecase.rs
@@ -1,141 +0,0 @@
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}
Deleted lib/gitbaby/src/blame/helper.rs
@@ -1,137 +0,0 @@
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}
Deleted lib/gitbaby/src/blame/mod.rs
@@ -1,6 +0,0 @@
1pub mod helper;
2pub mod types;
3pub mod usecase;
4
5pub use types::{BlameHunk, BlameOptions, Range};
6pub use usecase::ChildBlameReader;
Deleted lib/gitbaby/src/blame/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/blob/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/blob/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/blob/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/branch/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/branch/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/branch/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/cache/config.rs

Diff too large to display.

Deleted lib/gitbaby/src/cache/global.rs

Diff too large to display.

Deleted lib/gitbaby/src/cache/invalidation.rs

Diff too large to display.

Deleted lib/gitbaby/src/cache/key.rs

Diff too large to display.

Deleted lib/gitbaby/src/cache/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/cache/pools.rs

Diff too large to display.

Deleted lib/gitbaby/src/cache/stats.rs

Diff too large to display.

Deleted lib/gitbaby/src/cache/store.rs

Diff too large to display.

Deleted lib/gitbaby/src/cleanup/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/cleanup/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/cleanup/types.rs

Diff too large to display.

Deleted lib/gitbaby/src/cleanup/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/command/cmd.rs

Diff too large to display.

Deleted lib/gitbaby/src/command/context.rs

Diff too large to display.

Deleted lib/gitbaby/src/command/env.rs

Diff too large to display.

Deleted lib/gitbaby/src/command/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/command/pipe.rs

Diff too large to display.

Deleted lib/gitbaby/src/commit/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/commit/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/commit/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/compare/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/compare/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/compare/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/config/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/config/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/config/types.rs

Diff too large to display.

Deleted lib/gitbaby/src/config/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/conflict/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/conflict/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/conflict/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/diff/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/diff/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/diff/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/merge/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/merge/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/merge/types.rs

Diff too large to display.

Deleted lib/gitbaby/src/merge/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/refs/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/refs/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/refs/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/remote/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/remote/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/remote/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/server/backend.rs

Diff too large to display.

Deleted lib/gitbaby/src/server/endpoint.rs

Diff too large to display.

Deleted lib/gitbaby/src/server/error.rs

Diff too large to display.

Deleted lib/gitbaby/src/server/limits.rs

Diff too large to display.

Deleted lib/gitbaby/src/server/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/server/request.rs

Diff too large to display.

Deleted lib/gitbaby/src/server/stream.rs

Diff too large to display.

Deleted lib/gitbaby/src/setup/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/setup/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/setup/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/share/cmd.rs

Diff too large to display.

Deleted lib/gitbaby/src/share/env.rs

Diff too large to display.

Deleted lib/gitbaby/src/share/error.rs

Diff too large to display.

Deleted lib/gitbaby/src/share/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/share/path.rs

Diff too large to display.

Deleted lib/gitbaby/src/submodule/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/submodule/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/submodule/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/tags/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/tags/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/tags/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/tree/helper.rs

Diff too large to display.

Deleted lib/gitbaby/src/tree/mod.rs

Diff too large to display.

Deleted lib/gitbaby/src/tree/usecase.rs

Diff too large to display.

Deleted lib/gitbaby/src/lib.rs

Diff too large to display.

Deleted lib/gitbaby/src/repo.rs

Diff too large to display.

Deleted lib/gitbaby/tests/archive.rs

Diff too large to display.

Deleted lib/gitbaby/tests/cmd_run.rs

Diff too large to display.

Deleted lib/gitbaby/tests/context.rs

Diff too large to display.

Deleted lib/gitbaby/tests/env.rs

Diff too large to display.

Deleted lib/gitbaby/tests/pipe.rs

Diff too large to display.

Deleted lib/gitbaby/tests/server.rs

Diff too large to display.

Deleted lib/gitbaby/tests/tree.rs

Diff too large to display.

Deleted lib/gitbaby/AGENTS.md
@@ -1,52 +0,0 @@
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
Deleted lib/gitbaby/Cargo.toml
@@ -1,13 +0,0 @@
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
Deleted lib/gitbaby/README.md
@@ -1,175 +0,0 @@
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 lib/import/src/errors.rs View lib/import/src/errors.rs
@@ -62,7 +62,9 @@
6262 G::Unauthorized => Self::Unauthorized,
6363 G::Forbidden => Self::Forbidden,
6464 G::Conflict(msg) => Self::Conflict(msg),
65 G::Cache(_) | G::Git(_) | G::Internal(_) => Self::Internal(error.to_string()),
65 G::Cache(_) | G::Git(_) | G::Internal(_) | G::Unimplemented(_) => {
66 Self::Internal(error.to_string())
67 }
6668 }
6769 }
6870}
Modified lib/import/src/worker.rs View lib/import/src/worker.rs
@@ -1,13 +1,11 @@
1use std::path::{Path, PathBuf};
21use std::sync::Arc;
32use std::time::Duration;
43
5use cache::CacheClient;
6use config::git::SyncSettings;
74use config::import::ImportConfig;
85use export::{AppDatabase, AppRedis};
9use git::service::clone::{GitCredential, mirror_fetch};
10use git::sync::RepoSync;
6use kenshell::KenShellClient;
7use kenshell::proto::common::v1::RepositoryRef;
8use kenshell::proto::v1 as pb;
119use models::import::job;
1210use models::repo::repos;
1311use sea_orm::sea_query::Expr;
@@ -27,9 +25,7 @@
2725pub struct ImportWorker {
2826 db: Arc<AppDatabase>,
2927 redis: AppRedis,
30 cache: Arc<CacheClient>,
31 repo_root: PathBuf,
32 settings: SyncSettings,
28 ks: KenShellClient,
3329 config: ImportConfig,
3430 cancel: CancellationToken,
3531}
@@ -39,18 +35,14 @@
3935 pub fn new(
4036 db: Arc<AppDatabase>,
4137 redis: AppRedis,
42 cache: Arc<CacheClient>,
43 repo_root: PathBuf,
44 settings: SyncSettings,
38 ks: KenShellClient,
4539 config: ImportConfig,
4640 cancel: CancellationToken,
4741 ) -> Self {
4842 Self {
4943 db,
5044 redis,
51 cache,
52 repo_root,
53 settings,
45 ks,
5446 config,
5547 cancel,
5648 }
@@ -318,48 +310,53 @@
318310 .ok_or_else(|| {
319311 ImportError::Internal(format!("repository {repo_id} no longer exists"))
320312 })?;
313 let space = git::share::access::space_for_dir(self.db.read(), repo.dir_id)
314 .await
315 .map_err(|error| ImportError::Internal(format!("resolve space: {error}")))?
316 .ok_or_else(|| ImportError::Internal("owning space not found".to_string()))?;
321317 let provider = ImportProvider::parse(&row.provider)?;
322318 let options: ImportOptions =
323319 serde_json::from_value(row.options.clone()).unwrap_or_default();
324320
325321 self.check_canceled(row.id).await?;
326322 self.set_stage(row.id, ImportStage::Clone).await?;
327 let credential = match row.token_enc.as_deref() {
328 Some(stored) => {
329 let token = decrypt_token(&self.config.token_secret, stored)?;
330 let username = options
323
324 let auth = match row.token_enc.as_deref() {
325 Some(stored) => Some(pb::RemoteAuth {
326 username: options
331327 .credential_username
332328 .clone()
333 .unwrap_or_else(|| provider.default_credential_username().to_string());
334 Some(GitCredential::new(username, token))
335 }
329 .unwrap_or_else(|| provider.default_credential_username().to_string()),
330 token: decrypt_token(&self.config.token_secret, stored)?,
331 authorization_header: String::new(),
332 ssh_key: String::new(),
333 known_hosts: String::new(),
334 resolved_address: String::new(),
335 }),
336336 None => None,
337337 };
338 let dir = git::context::facade::repo_dir_for(&self.repo_root, repo_id);
339 let result = mirror_fetch(
340 "import::mirror",
341 &dir,
342 &row.clone_url,
343 credential.as_ref(),
344 self.config.clone_timeout_secs.max(1),
345 )
346 .await;
347 drop(credential);
348 result?;
349338
339 let mut remote = self.ks.remote();
340 let job = remote
341 .start_remote_import(pb::StartRemoteImportRequest {
342 repository: Some(RepositoryRef {
343 org_name: space.lower_name.clone(),
344 repo_name: repo.name.clone(),
345 }),
346 url: row.clone_url.clone(),
347 auth,
348 actor: None,
349 idempotency_key: row.id.to_string(),
350 })
351 .await
352 .map_err(|status| ImportError::Internal(status.message().to_string()))?
353 .into_inner()
354 .job
355 .ok_or_else(|| ImportError::Internal("import job missing".to_string()))?;
356
350357 self.check_canceled(row.id).await?;
351358 self.set_stage(row.id, ImportStage::Sync).await?;
352 let sync = RepoSync::new(
353 self.db.clone(),
354 self.redis.clone(),
355 self.cache.clone(),
356 repo,
357 self.repo_root.clone(),
358 self.settings.clone(),
359 );
360 sync.sync()
361 .await
362 .map_err(|e| ImportError::Internal(format!("repository sync failed: {e}")))?;
359 let result = self.wait_import_job(&job.id, row.id).await?;
363360
364361 self.check_canceled(row.id).await?;
365362 self.set_stage(row.id, ImportStage::Finalize).await?;
@@ -369,62 +366,75 @@
369366 .ok_or_else(|| {
370367 ImportError::Internal(format!("repository {repo_id} disappeared during import"))
371368 })?;
372 let default_branch = fresh
373 .default_branch
374 .strip_prefix("refs/heads/")
375 .unwrap_or(&fresh.default_branch)
376 .to_string();
377 let mut stats = ImportStatsDto {
369 let default_branch = if result.default_branch.is_empty() {
370 fresh
371 .default_branch
372 .strip_prefix("refs/heads/")
373 .unwrap_or(&fresh.default_branch)
374 .to_string()
375 } else {
376 result
377 .default_branch
378 .strip_prefix("refs/heads/")
379 .unwrap_or(&result.default_branch)
380 .to_string()
381 };
382 let stats = ImportStatsDto {
378383 branches: i64::from(fresh.nums_branches),
379384 tags: i64::from(fresh.nums_tags),
380 commits: i64::from(fresh.nums_commits),
385 commits: i64::from(fresh.nums_commits).max(result.commit_count as i64),
381386 size_bytes: fresh.git_size,
382387 default_branch: Some(default_branch),
383388 warnings: Vec::new(),
384389 };
385 if uses_lfs(
386 &dir,
387 &fresh.default_branch,
388 self.settings.git_command_timeout_secs,
389 )
390 .await
391 {
392 stats.warnings.push(
393 "源仓库使用 Git LFS,本次导入只搬运了指针文件,未搬迁 LFS 大对象。".to_string(),
394 );
395 }
396390 if let Err(error) = identity::harvest(self.db.write(), provider, repo_id, &options).await {
397391 warn!(%error, job_id = %row.id, "import identity harvest failed");
398392 }
399393 Ok(stats)
400394 }
401}
402395
403async fn uses_lfs(dir: &Path, default_branch: &str, timeout_secs: u64) -> bool {
404 let revision = default_branch
405 .strip_prefix("refs/heads/")
406 .unwrap_or(default_branch);
407 if revision.is_empty() {
408 return false;
409 }
410 let output = git::service::gitcmd::run_git_soft(
411 "import::lfs_probe",
412 dir,
413 vec![
414 "grep".to_string(),
415 "-l".to_string(),
416 "--fixed-strings".to_string(),
417 "filter=lfs".to_string(),
418 revision.to_string(),
419 "--".to_string(),
420 ".gitattributes".to_string(),
421 ],
422 &[],
423 timeout_secs.clamp(5, 60),
424 )
425 .await;
426 match output {
427 Ok(output) => output.status.success() && !output.stdout.is_empty(),
428 Err(_) => false,
396 async fn wait_import_job(
397 &self,
398 job_id: &str,
399 local_id: Uuid,
400 ) -> Result<pb::RemoteJobResult, ImportError> {
401 let mut client = self.ks.job();
402 loop {
403 self.check_canceled(local_id).await.map_err(|error| {
404 if matches!(error, ImportError::Canceled) {
405 error
406 } else {
407 ImportError::Internal(error.to_string())
408 }
409 })?;
410 let job = client
411 .get_job(pb::GetJobRequest {
412 id: job_id.to_string(),
413 })
414 .await
415 .map_err(|status| ImportError::Internal(status.message().to_string()))?
416 .into_inner()
417 .job
418 .ok_or_else(|| ImportError::Internal("import job missing".to_string()))?;
419 match pb::JobState::try_from(job.state).unwrap_or(pb::JobState::Unspecified) {
420 pb::JobState::Succeeded => {
421 return match job.result {
422 Some(pb::job::Result::Remote(remote)) => Ok(remote),
423 _ => Err(ImportError::Internal(
424 "import job returned no result".to_string(),
425 )),
426 };
427 }
428 pb::JobState::Failed | pb::JobState::Canceled => {
429 return Err(ImportError::Internal(if job.error_message.is_empty() {
430 "import job failed".to_string()
431 } else {
432 job.error_message
433 }));
434 }
435 _ => {}
436 }
437 tokio::time::sleep(Duration::from_millis(500)).await;
438 }
429439 }
430440}
Modified lib/import/Cargo.toml View lib/import/Cargo.toml
@@ -11,6 +11,7 @@
1111config = { workspace = true }
1212export = { workspace = true }
1313git = { workspace = true }
14kenshell = { workspace = true }
1415models = { workspace = true }
1516reqwest = { workspace = true }
1617sea-orm = { workspace = true }
Modified lib/pulls/src/errors.rs View lib/pulls/src/errors.rs
@@ -41,7 +41,9 @@
4141 G::Unauthorized => Self::Unauthorized,
4242 G::Forbidden => Self::Forbidden,
4343 G::Conflict(msg) => Self::Conflict(msg),
44 G::Cache(_) | G::Git(_) | G::Internal(_) => Self::Internal(error.to_string()),
44 G::Cache(_) | G::Git(_) | G::Internal(_) | G::Unimplemented(_) => {
45 Self::Internal(error.to_string())
46 }
4547 }
4648 }
4749}
Modified Cargo.lock View Cargo.lock
@@ -3,12 +3,6 @@
33version = 4
44
55[[package]]
6name = "Inflector"
7version = "0.11.4"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
10
11[[package]]
126name = "adler2"
137version = "2.0.1"
148source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -21,7 +15,7 @@
2115checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
2216dependencies = [
2317 "crypto-common 0.1.6",
24 "generic-array 0.14.9",
18 "generic-array",
2519]
2620
2721[[package]]
@@ -54,7 +48,6 @@
5448 "cipher 0.5.2",
5549 "cpubits",
5650 "cpufeatures 0.3.0",
57 "zeroize",
5851]
5952
6053[[package]]
@@ -83,7 +76,6 @@
8376 "ctr 0.10.1",
8477 "ghash 0.6.0",
8578 "subtle",
86 "zeroize",
8779]
8880
8981[[package]]
@@ -401,12 +393,6 @@
401393]
402394
403395[[package]]
404name = "ascii_utils"
405version = "0.9.3"
406source = "registry+https://github.com/rust-lang/crates.io-index"
407checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a"
408
409[[package]]
410396name = "async-compression"
411397version = "0.4.36"
412398source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -420,98 +406,6 @@
420406]
421407
422408[[package]]
423name = "async-graphql"
424version = "7.2.1"
425source = "registry+https://github.com/rust-lang/crates.io-index"
426checksum = "1057a9f7ccf2404d94571dec3451ade1cb524790df6f1ada0d19c2a49f6b0f40"
427dependencies = [
428 "async-graphql-derive",
429 "async-graphql-parser",
430 "async-graphql-value",
431 "async-io",
432 "async-trait",
433 "asynk-strim",
434 "base64 0.22.1",
435 "bytes",
436 "fast_chemail",
437 "fnv",
438 "futures-util",
439 "handlebars",
440 "http 1.5.0",
441 "indexmap",
442 "mime 0.3.17",
443 "multer",
444 "num-traits",
445 "pin-project-lite",
446 "regex",
447 "serde",
448 "serde_json",
449 "serde_urlencoded",
450 "static_assertions_next",
451 "tempfile",
452 "thiserror 2.0.20",
453]
454
455[[package]]
456name = "async-graphql-derive"
457version = "7.2.1"
458source = "registry+https://github.com/rust-lang/crates.io-index"
459checksum = "2e6cbeadc8515e66450fba0985ce722192e28443697799988265d86304d7cc68"
460dependencies = [
461 "Inflector",
462 "async-graphql-parser",
463 "darling 0.23.0",
464 "proc-macro-crate",
465 "proc-macro2",
466 "quote",
467 "strum 0.27.2",
468 "syn 2.0.119",
469 "thiserror 2.0.20",
470]
471
472[[package]]
473name = "async-graphql-parser"
474version = "7.2.1"
475source = "registry+https://github.com/rust-lang/crates.io-index"
476checksum = "e64ef70f77a1c689111e52076da1cd18f91834bcb847de0a9171f83624b07fbf"
477dependencies = [
478 "async-graphql-value",
479 "pest",
480 "serde",
481 "serde_json",
482]
483
484[[package]]
485name = "async-graphql-value"
486version = "7.2.1"
487source = "registry+https://github.com/rust-lang/crates.io-index"
488checksum = "3e3ef112905abea9dea592fc868a6873b10ebd3f983e83308f995d6284e9ba41"
489dependencies = [
490 "bytes",
491 "indexmap",
492 "serde",
493 "serde_json",
494]
495
496[[package]]
497name = "async-io"
498version = "2.6.0"
499source = "registry+https://github.com/rust-lang/crates.io-index"
500checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
501dependencies = [
502 "autocfg",
503 "cfg-if",
504 "concurrent-queue",
505 "futures-io",
506 "futures-lite",
507 "parking",
508 "polling",
509 "rustix",
510 "slab",
511 "windows-sys 0.61.2",
512]
513
514[[package]]
515409name = "async-lock"
516410version = "3.4.2"
517411source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -556,16 +450,6 @@
556450]
557451
558452[[package]]
559name = "asynk-strim"
560version = "0.1.5"
561source = "registry+https://github.com/rust-lang/crates.io-index"
562checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6"
563dependencies = [
564 "futures-core",
565 "pin-project-lite",
566]
567
568[[package]]
569453name = "atoi"
570454version = "2.0.0"
571455source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -668,7 +552,6 @@
668552checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
669553dependencies = [
670554 "aws-lc-sys",
671 "untrusted 0.7.1",
672555 "zeroize",
673556]
674557
@@ -779,12 +662,6 @@
779662checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
780663
781664[[package]]
782name = "base16ct"
783version = "1.0.0"
784source = "registry+https://github.com/rust-lang/crates.io-index"
785checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
786
787[[package]]
788665name = "base64"
789666version = "0.9.3"
790667source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -819,17 +696,6 @@
819696checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
820697
821698[[package]]
822name = "bcrypt-pbkdf"
823version = "0.11.0"
824source = "registry+https://github.com/rust-lang/crates.io-index"
825checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0"
826dependencies = [
827 "blowfish",
828 "pbkdf2",
829 "sha2 0.11.0",
830]
831
832[[package]]
833699name = "bigdecimal"
834700version = "0.4.10"
835701source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -848,21 +714,6 @@
848714version = "0.1.7"
849715source = "registry+https://github.com/rust-lang/crates.io-index"
850716checksum = "0669d5a35b64fdb5ab7fb19cae13148b6b5cbdf4b8247faf54ece47f699c8cef"
851
852[[package]]
853name = "bisync"
854version = "0.3.0"
855source = "registry+https://github.com/rust-lang/crates.io-index"
856checksum = "5020822f6d6f23196ccaf55e228db36f9de1cf788052b37992e17cbc96ec41a7"
857dependencies = [
858 "bisync_macros",
859]
860
861[[package]]
862name = "bisync_macros"
863version = "0.2.3"
864source = "registry+https://github.com/rust-lang/crates.io-index"
865checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6"
866717
867718[[package]]
868719name = "bitflags"
@@ -916,7 +767,7 @@
916767source = "registry+https://github.com/rust-lang/crates.io-index"
917768checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
918769dependencies = [
919 "generic-array 0.14.9",
770 "generic-array",
920771]
921772
922773[[package]]
@@ -926,16 +777,6 @@
926777checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
927778dependencies = [
928779 "hybrid-array",
929 "zeroize",
930]
931
932[[package]]
933name = "block-padding"
934version = "0.4.2"
935source = "registry+https://github.com/rust-lang/crates.io-index"
936checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
937dependencies = [
938 "hybrid-array",
939780]
940781
941782[[package]]
@@ -948,16 +789,6 @@
948789]
949790
950791[[package]]
951name = "blowfish"
952version = "0.10.0"
953source = "registry+https://github.com/rust-lang/crates.io-index"
954checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298"
955dependencies = [
956 "byteorder",
957 "cipher 0.5.2",
958]
959
960[[package]]
961792name = "borsh"
962793version = "1.8.0"
963794source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1009,7 +840,6 @@
1009840checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
1010841dependencies = [
1011842 "memchr",
1012 "regex-automata",
1013843 "serde_core",
1014844]
1015845
@@ -1078,15 +908,6 @@
1078908version = "1.12.1"
1079909source = "registry+https://github.com/rust-lang/crates.io-index"
1080910checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
1081dependencies = [
1082 "serde",
1083]
1084
1085[[package]]
1086name = "bytesize"
1087version = "2.7.0"
1088source = "registry+https://github.com/rust-lang/crates.io-index"
1089checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b"
1090911
1091912[[package]]
1092913name = "cache"
@@ -1099,15 +920,6 @@
1099920]
1100921
1101922[[package]]
1102name = "cbc"
1103version = "0.2.1"
1104source = "registry+https://github.com/rust-lang/crates.io-index"
1105checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
1106dependencies = [
1107 "cipher 0.5.2",
1108]
1109
1110[[package]]
1111923name = "cc"
1112924version = "1.4.3"
1113925source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1144,10 +956,8 @@
1144956checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
1145957dependencies = [
1146958 "cfg-if",
1147 "cipher 0.5.2",
1148959 "cpufeatures 0.3.0",
1149960 "rand_core 0.10.1",
1150 "zeroize",
1151961]
1152962
1153963[[package]]
@@ -1219,7 +1029,6 @@
12191029 "export",
12201030 "futures",
12211031 "git",
1222 "gitbaby",
12231032 "globset",
12241033 "hmac 0.13.0",
12251034 "jwt",
@@ -1273,16 +1082,6 @@
12731082 "block-buffer 0.12.1",
12741083 "crypto-common 0.2.2",
12751084 "inout 0.2.2",
1276 "zeroize",
1277]
1278
1279[[package]]
1280name = "clru"
1281version = "0.6.3"
1282source = "registry+https://github.com/rust-lang/crates.io-index"
1283checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5"
1284dependencies = [
1285 "hashbrown 0.16.1",
12861085]
12871086
12881087[[package]]
@@ -1301,12 +1100,6 @@
13011100checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
13021101
13031102[[package]]
1304name = "cmsketch"
1305version = "0.2.4"
1306source = "registry+https://github.com/rust-lang/crates.io-index"
1307checksum = "d7ee2cfacbd29706479902b06d75ad8f1362900836aa32799eabc7e004bfd854"
1308
1309[[package]]
13101103name = "coarsetime"
13111104version = "0.1.37"
13121105source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1365,15 +1158,6 @@
13651158checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
13661159
13671160[[package]]
1368name = "concurrent-queue"
1369version = "2.5.0"
1370source = "registry+https://github.com/rust-lang/crates.io-index"
1371checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
1372dependencies = [
1373 "crossbeam-utils",
1374]
1375
1376[[package]]
13771161name = "config"
13781162version = "1.0.0"
13791163dependencies = [
@@ -1443,17 +1227,6 @@
14431227checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
14441228
14451229[[package]]
1446name = "core_affinity"
1447version = "0.8.3"
1448source = "registry+https://github.com/rust-lang/crates.io-index"
1449checksum = "a034b3a7b624016c6e13f5df875747cc25f884156aad2abd12b6c46797971342"
1450dependencies = [
1451 "libc",
1452 "num_cpus",
1453 "winapi",
1454]
1455
1456[[package]]
14571230name = "cpubits"
14581231version = "0.1.1"
14591232source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1505,15 +1278,6 @@
15051278checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
15061279dependencies = [
15071280 "cfg-if",
1508]
1509
1510[[package]]
1511name = "crossbeam-channel"
1512version = "0.5.16"
1513source = "registry+https://github.com/rust-lang/crates.io-index"
1514checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
1515dependencies = [
1516 "crossbeam-utils",
15171281]
15181282
15191283[[package]]
@@ -1543,36 +1307,19 @@
15431307source = "registry+https://github.com/rust-lang/crates.io-index"
15441308checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
15451309dependencies = [
1546 "generic-array 0.14.9",
1310 "generic-array",
15471311 "rand_core 0.6.4",
15481312 "subtle",
15491313 "zeroize",
15501314]
15511315
15521316[[package]]
1553name = "crypto-bigint"
1554version = "0.7.5"
1317name = "crypto-common"
1318version = "0.1.6"
15551319source = "registry+https://github.com/rust-lang/crates.io-index"
1556checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
1320checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
15571321dependencies = [
1558 "cpubits",
1559 "ctutils",
1560 "getrandom 0.4.3",
1561 "hybrid-array",
1562 "num-traits",
1563 "rand_core 0.10.1",
1564 "serdect",
1565 "subtle",
1566 "zeroize",
1567]
1568
1569[[package]]
1570name = "crypto-common"
1571version = "0.1.6"
1572source = "registry+https://github.com/rust-lang/crates.io-index"
1573checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
1574dependencies = [
1575 "generic-array 0.14.9",
1322 "generic-array",
15761323 "rand_core 0.6.4",
15771324 "typenum",
15781325]
@@ -1589,16 +1336,6 @@
15891336]
15901337
15911338[[package]]
1592name = "crypto-primes"
1593version = "0.7.2"
1594source = "registry+https://github.com/rust-lang/crates.io-index"
1595checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f"
1596dependencies = [
1597 "crypto-bigint 0.7.5",
1598 "rand_core 0.10.1",
1599]
1600
1601[[package]]
16021339name = "ct-codecs"
16031340version = "1.1.7"
16041341source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1629,35 +1366,6 @@
16291366checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
16301367dependencies = [
16311368 "cmov",
1632 "subtle",
1633]
1634
1635[[package]]
1636name = "curve25519-dalek"
1637version = "5.0.0"
1638source = "registry+https://github.com/rust-lang/crates.io-index"
1639checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
1640dependencies = [
1641 "cfg-if",
1642 "cpufeatures 0.3.0",
1643 "curve25519-dalek-derive",
1644 "digest 0.11.3",
1645 "fiat-crypto",
1646 "rand_core 0.10.1",
1647 "rustc_version",
1648 "subtle",
1649 "zeroize",
1650]
1651
1652[[package]]
1653name = "curve25519-dalek-derive"
1654version = "0.1.1"
1655source = "registry+https://github.com/rust-lang/crates.io-index"
1656checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
1657dependencies = [
1658 "proc-macro2",
1659 "quote",
1660 "syn 2.0.119",
16611369]
16621370
16631371[[package]]
@@ -1666,18 +1374,8 @@
16661374source = "registry+https://github.com/rust-lang/crates.io-index"
16671375checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
16681376dependencies = [
1669 "darling_core 0.20.11",
1670 "darling_macro 0.20.11",
1671]
1672
1673[[package]]
1674name = "darling"
1675version = "0.23.0"
1676source = "registry+https://github.com/rust-lang/crates.io-index"
1677checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
1678dependencies = [
1679 "darling_core 0.23.0",
1680 "darling_macro 0.23.0",
1377 "darling_core",
1378 "darling_macro",
16811379]
16821380
16831381[[package]]
@@ -1690,20 +1388,6 @@
16901388 "ident_case",
16911389 "proc-macro2",
16921390 "quote",
1693 "strsim",
1694 "syn 2.0.119",
1695]
1696
1697[[package]]
1698name = "darling_core"
1699version = "0.23.0"
1700source = "registry+https://github.com/rust-lang/crates.io-index"
1701checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
1702dependencies = [
1703 "ident_case",
1704 "proc-macro2",
1705 "quote",
1706 "strsim",
17071391 "syn 2.0.119",
17081392]
17091393
@@ -1713,83 +1397,16 @@
17131397source = "registry+https://github.com/rust-lang/crates.io-index"
17141398checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
17151399dependencies = [
1716 "darling_core 0.20.11",
1400 "darling_core",
17171401 "quote",
17181402 "syn 2.0.119",
17191403]
17201404
17211405[[package]]
1722name = "darling_macro"
1723version = "0.23.0"
1724source = "registry+https://github.com/rust-lang/crates.io-index"
1725checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
1726dependencies = [
1727 "darling_core 0.23.0",
1728 "quote",
1729 "syn 2.0.119",
1730]
1731
1732[[package]]
1733name = "dashmap"
1734version = "6.2.1"
1735source = "registry+https://github.com/rust-lang/crates.io-index"
1736checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
1737dependencies = [
1738 "cfg-if",
1739 "crossbeam-utils",
1740 "hashbrown 0.14.5",
1741 "lock_api",
1742 "once_cell",
1743 "parking_lot_core",
1744]
1745
1746[[package]]
17471406name = "data-encoding"
17481407version = "2.11.1"
17491408source = "registry+https://github.com/rust-lang/crates.io-index"
17501409checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
1751
1752[[package]]
1753name = "defmt"
1754version = "1.1.1"
1755source = "registry+https://github.com/rust-lang/crates.io-index"
1756checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
1757dependencies = [
1758 "bitflags 1.3.2",
1759 "defmt-macros",
1760]
1761
1762[[package]]
1763name = "defmt-macros"
1764version = "1.1.1"
1765source = "registry+https://github.com/rust-lang/crates.io-index"
1766checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
1767dependencies = [
1768 "defmt-parser",
1769 "proc-macro2",
1770 "quote",
1771 "syn 2.0.119",
1772]
1773
1774[[package]]
1775name = "defmt-parser"
1776version = "1.0.0"
1777source = "registry+https://github.com/rust-lang/crates.io-index"
1778checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
1779dependencies = [
1780 "thiserror 2.0.20",
1781]
1782
1783[[package]]
1784name = "delegate"
1785version = "0.13.5"
1786source = "registry+https://github.com/rust-lang/crates.io-index"
1787checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370"
1788dependencies = [
1789 "proc-macro2",
1790 "quote",
1791 "syn 2.0.119",
1792]
17931410
17941411[[package]]
17951412name = "der"
@@ -1798,7 +1415,7 @@
17981415checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
17991416dependencies = [
18001417 "const-oid 0.9.6",
1801 "pem-rfc7468 0.7.0",
1418 "pem-rfc7468",
18021419 "zeroize",
18031420]
18041421
@@ -1809,7 +1426,6 @@
18091426checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
18101427dependencies = [
18111428 "const-oid 0.10.2",
1812 "pem-rfc7468 1.0.0",
18131429 "zeroize",
18141430]
18151431
@@ -1845,37 +1461,6 @@
18451461]
18461462
18471463[[package]]
1848name = "derive_builder"
1849version = "0.20.2"
1850source = "registry+https://github.com/rust-lang/crates.io-index"
1851checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
1852dependencies = [
1853 "derive_builder_macro",
1854]
1855
1856[[package]]
1857name = "derive_builder_core"
1858version = "0.20.2"
1859source = "registry+https://github.com/rust-lang/crates.io-index"
1860checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
1861dependencies = [
1862 "darling 0.20.11",
1863 "proc-macro2",
1864 "quote",
1865 "syn 2.0.119",
1866]
1867
1868[[package]]
1869name = "derive_builder_macro"
1870version = "0.20.2"
1871source = "registry+https://github.com/rust-lang/crates.io-index"
1872checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
1873dependencies = [
1874 "derive_builder_core",
1875 "syn 2.0.119",
1876]
1877
1878[[package]]
18791464name = "derive_more"
18801465version = "2.1.1"
18811466source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1895,15 +1480,6 @@
18951480 "rustc_version",
18961481 "syn 2.0.119",
18971482 "unicode-xid",
1898]
1899
1900[[package]]
1901name = "des"
1902version = "0.9.0"
1903source = "registry+https://github.com/rust-lang/crates.io-index"
1904checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a"
1905dependencies = [
1906 "cipher 0.5.2",
19071483]
19081484
19091485[[package]]
@@ -2013,28 +1589,13 @@
20131589dependencies = [
20141590 "der 0.7.10",
20151591 "digest 0.10.7",
2016 "elliptic-curve 0.13.8",
2017 "rfc6979 0.4.0",
1592 "elliptic-curve",
1593 "rfc6979",
20181594 "signature 2.2.0",
20191595 "spki 0.7.3",
20201596]
20211597
20221598[[package]]
2023name = "ecdsa"
2024version = "0.17.0"
2025source = "registry+https://github.com/rust-lang/crates.io-index"
2026checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
2027dependencies = [
2028 "der 0.8.1",
2029 "digest 0.11.3",
2030 "elliptic-curve 0.14.1",
2031 "rfc6979 0.6.0",
2032 "signature 3.0.0",
2033 "spki 0.8.0",
2034 "zeroize",
2035]
2036
2037[[package]]
20381599name = "ece-native"
20391600version = "0.5.0"
20401601source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2046,16 +1607,6 @@
20461607]
20471608
20481609[[package]]
2049name = "ed25519"
2050version = "3.0.0"
2051source = "registry+https://github.com/rust-lang/crates.io-index"
2052checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
2053dependencies = [
2054 "pkcs8 0.11.0",
2055 "signature 3.0.0",
2056]
2057
2058[[package]]
20591610name = "ed25519-compact"
20601611version = "2.4.2"
20611612source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2063,22 +1614,6 @@
20631614dependencies = [
20641615 "ct-codecs",
20651616 "getrandom 0.4.3",
2066]
2067
2068[[package]]
2069name = "ed25519-dalek"
2070version = "3.0.0"
2071source = "registry+https://github.com/rust-lang/crates.io-index"
2072checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
2073dependencies = [
2074 "curve25519-dalek",
2075 "ed25519",
2076 "rand_core 0.10.1",
2077 "serde",
2078 "sha2 0.11.0",
2079 "signature 3.0.0",
2080 "subtle",
2081 "zeroize",
20821617]
20831618
20841619[[package]]
@@ -2096,65 +1631,31 @@
20961631source = "registry+https://github.com/rust-lang/crates.io-index"
20971632checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
20981633dependencies = [
2099 "base16ct 0.2.0",
2100 "crypto-bigint 0.5.5",
1634 "base16ct",
1635 "crypto-bigint",
21011636 "digest 0.10.7",
2102 "ff 0.13.1",
2103 "generic-array 0.14.9",
2104 "group 0.13.0",
1637 "ff",
1638 "generic-array",
1639 "group",
21051640 "hkdf 0.12.4",
2106 "pem-rfc7468 0.7.0",
1641 "pem-rfc7468",
21071642 "pkcs8 0.10.2",
21081643 "rand_core 0.6.4",
2109 "sec1 0.7.3",
1644 "sec1",
21101645 "subtle",
21111646 "zeroize",
21121647]
21131648
21141649[[package]]
2115name = "elliptic-curve"
2116version = "0.14.1"
1650name = "encoding_rs"
1651version = "0.8.35"
21171652source = "registry+https://github.com/rust-lang/crates.io-index"
2118checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
1653checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
21191654dependencies = [
2120 "base16ct 1.0.0",
2121 "crypto-bigint 0.7.5",
2122 "crypto-common 0.2.2",
2123 "digest 0.11.3",
2124 "ff 0.14.0",
2125 "group 0.14.0",
2126 "hkdf 0.13.0",
2127 "hybrid-array",
2128 "pem-rfc7468 1.0.0",
2129 "pkcs8 0.11.0",
2130 "rand_core 0.10.1",
2131 "sec1 0.8.1",
2132 "subtle",
2133 "zeroize",
2134]
2135
2136[[package]]
2137name = "encoding_rs"
2138version = "0.8.35"
2139source = "registry+https://github.com/rust-lang/crates.io-index"
2140checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
2141dependencies = [
21421655 "cfg-if",
21431656]
21441657
21451658[[package]]
2146name = "enum_dispatch"
2147version = "0.3.13"
2148source = "registry+https://github.com/rust-lang/crates.io-index"
2149checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
2150dependencies = [
2151 "once_cell",
2152 "proc-macro2",
2153 "quote",
2154 "syn 2.0.119",
2155]
2156
2157[[package]]
21581659name = "equivalent"
21591660version = "1.0.2"
21601661source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2244,35 +1745,6 @@
22441745]
22451746
22461747[[package]]
2247name = "fast_chemail"
2248version = "0.9.6"
2249source = "registry+https://github.com/rust-lang/crates.io-index"
2250checksum = "495a39d30d624c2caabe6312bfead73e7717692b44e0b32df168c275a2e8e9e4"
2251dependencies = [
2252 "ascii_utils",
2253]
2254
2255[[package]]
2256name = "fastant"
2257version = "0.1.11"
2258source = "registry+https://github.com/rust-lang/crates.io-index"
2259checksum = "2e825441bfb2d831c47c97d05821552db8832479f44c571b97fededbf0099c07"
2260dependencies = [
2261 "small_ctor",
2262 "web-time",
2263]
2264
2265[[package]]
2266name = "faster-hex"
2267version = "0.10.0"
2268source = "registry+https://github.com/rust-lang/crates.io-index"
2269checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73"
2270dependencies = [
2271 "heapless",
2272 "serde",
2273]
2274
2275[[package]]
22761748name = "fastrand"
22771749version = "2.5.0"
22781750source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2304,22 +1776,6 @@
23041776]
23051777
23061778[[package]]
2307name = "ff"
2308version = "0.14.0"
2309source = "registry+https://github.com/rust-lang/crates.io-index"
2310checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f"
2311dependencies = [
2312 "rand_core 0.10.1",
2313 "subtle",
2314]
2315
2316[[package]]
2317name = "fiat-crypto"
2318version = "0.3.0"
2319source = "registry+https://github.com/rust-lang/crates.io-index"
2320checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
2321
2322[[package]]
23231779name = "figment"
23241780version = "0.10.19"
23251781source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2334,16 +1790,6 @@
23341790]
23351791
23361792[[package]]
2337name = "filetime"
2338version = "0.2.29"
2339source = "registry+https://github.com/rust-lang/crates.io-index"
2340checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
2341dependencies = [
2342 "cfg-if",
2343 "libc",
2344]
2345
2346[[package]]
23471793name = "find-msvc-tools"
23481794version = "0.1.11"
23491795source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2423,127 +1869,6 @@
24231869]
24241870
24251871[[package]]
2426name = "foyer"
2427version = "0.22.3"
2428source = "registry+https://github.com/rust-lang/crates.io-index"
2429checksum = "3b0abc0b87814989efa711f9becd9f26969820e2d3905db27d10969c4bd45890"
2430dependencies = [
2431 "anyhow",
2432 "equivalent",
2433 "foyer-common",
2434 "foyer-memory",
2435 "foyer-storage",
2436 "foyer-tokio",
2437 "futures-util",
2438 "mea",
2439 "mixtrics",
2440 "pin-project",
2441 "serde",
2442 "tracing",
2443]
2444
2445[[package]]
2446name = "foyer-common"
2447version = "0.22.3"
2448source = "registry+https://github.com/rust-lang/crates.io-index"
2449checksum = "a3db80d5dece93adb7ad709c84578794724a9cba342a7e566c3551c7ec626789"
2450dependencies = [
2451 "anyhow",
2452 "bytes",
2453 "cfg-if",
2454 "foyer-tokio",
2455 "mixtrics",
2456 "parking_lot",
2457 "pin-project",
2458 "twox-hash",
2459]
2460
2461[[package]]
2462name = "foyer-intrusive-collections"
2463version = "0.10.0-dev"
2464source = "registry+https://github.com/rust-lang/crates.io-index"
2465checksum = "6e4fee46bea69e0596130e3210e65d3424e0ac1e6df3bde6636304bdf1ca4a3b"
2466dependencies = [
2467 "memoffset",
2468]
2469
2470[[package]]
2471name = "foyer-memory"
2472version = "0.22.3"
2473source = "registry+https://github.com/rust-lang/crates.io-index"
2474checksum = "db907f40a527ca2aa2f40a5f68b32ea58aa70f050cd233518e9ffd402cfba6ce"
2475dependencies = [
2476 "anyhow",
2477 "bitflags 2.13.1",
2478 "cmsketch",
2479 "equivalent",
2480 "foyer-common",
2481 "foyer-intrusive-collections",
2482 "foyer-tokio",
2483 "futures-util",
2484 "hashbrown 0.16.1",
2485 "itertools 0.14.0",
2486 "mea",
2487 "mixtrics",
2488 "parking_lot",
2489 "paste",
2490 "pin-project",
2491 "serde",
2492 "tracing",
2493]
2494
2495[[package]]
2496name = "foyer-storage"
2497version = "0.22.3"
2498source = "registry+https://github.com/rust-lang/crates.io-index"
2499checksum = "1983f1db3d0710e9c9d5fc116d9202dccd41a2d1e032572224f1aff5520aa958"
2500dependencies = [
2501 "allocator-api2",
2502 "anyhow",
2503 "bytes",
2504 "core_affinity",
2505 "equivalent",
2506 "fastant",
2507 "foyer-common",
2508 "foyer-memory",
2509 "foyer-tokio",
2510 "fs4",
2511 "futures-core",
2512 "futures-util",
2513 "hashbrown 0.16.1",
2514 "io-uring",
2515 "itertools 0.14.0",
2516 "libc",
2517 "lz4",
2518 "mea",
2519 "parking_lot",
2520 "pin-project",
2521 "rand 0.9.5",
2522 "tracing",
2523 "twox-hash",
2524 "zstd",
2525]
2526
2527[[package]]
2528name = "foyer-tokio"
2529version = "0.22.3"
2530source = "registry+https://github.com/rust-lang/crates.io-index"
2531checksum = "f6577b05a7ffad0db555aedf00bfe52af818220fc4c1c3a7a12520896fc38627"
2532dependencies = [
2533 "tokio",
2534]
2535
2536[[package]]
2537name = "fs4"
2538version = "0.13.1"
2539source = "registry+https://github.com/rust-lang/crates.io-index"
2540checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4"
2541dependencies = [
2542 "rustix",
2543 "windows-sys 0.59.0",
2544]
2545
2546[[package]]
25471872name = "fs_extra"
25481873version = "1.3.0"
25491874source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2615,16 +1940,6 @@
26151940checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
26161941
26171942[[package]]
2618name = "futures-lite"
2619version = "2.6.1"
2620source = "registry+https://github.com/rust-lang/crates.io-index"
2621checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
2622dependencies = [
2623 "futures-core",
2624 "pin-project-lite",
2625]
2626
2627[[package]]
26281943name = "futures-macro"
26291944version = "0.3.34"
26301945source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2676,17 +1991,6 @@
26761991]
26771992
26781993[[package]]
2679name = "generic-array"
2680version = "1.4.5"
2681source = "registry+https://github.com/rust-lang/crates.io-index"
2682checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
2683dependencies = [
2684 "generic-array 0.14.9",
2685 "rustversion",
2686 "typenum",
2687]
2688
2689[[package]]
26901994name = "getrandom"
26911995version = "0.2.17"
26921996source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2758,7 +2062,6 @@
27582062name = "git"
27592063version = "1.0.0"
27602064dependencies = [
2761 "async-graphql",
27622065 "async-trait",
27632066 "axum",
27642067 "base64 0.23.1",
@@ -2769,18 +2072,17 @@
27692072 "export",
27702073 "flate2",
27712074 "futures",
2772 "gitbaby",
2773 "gix",
27742075 "globset",
27752076 "hayro",
27762077 "http-body-util",
27772078 "jwt",
2079 "kenshell",
27782080 "maud",
27792081 "models",
2082 "prost-types 0.14.1",
27802083 "pulldown-cmark",
27812084 "rand 0.8.7",
27822085 "redis",
2783 "russh",
27842086 "sea-orm",
27852087 "search",
27862088 "serde",
@@ -2791,979 +2093,18 @@
27912093 "time 0.3.55",
27922094 "tokio",
27932095 "tokio-util",
2096 "tonic",
27942097 "tracing",
27952098 "utoipa",
27962099 "uuid",
27972100]
27982101
27992102[[package]]
2800name = "gitbaby"
2801version = "1.0.0"
2802dependencies = [
2803 "async-trait",
2804 "foyer",
2805 "gix",
2806 "serde",
2807 "serde_json",
2808 "time 0.3.55",
2809 "tokio",
2810]
2811
2812[[package]]
2813name = "githost"
2814version = "1.0.0"
2103name = "glifo"
2104version = "0.1.1"
2105source = "registry+https://github.com/rust-lang/crates.io-index"
2106checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
28152107dependencies = [
2816 "anyhow",
2817 "axum",
2818 "config",
2819 "export",
2820 "git",
2821 "tokio",
2822 "tracing",
2823 "tracing-subscriber",
2824]
2825
2826[[package]]
2827name = "gix"
2828version = "0.86.0"
2829source = "registry+https://github.com/rust-lang/crates.io-index"
2830checksum = "bb3790fd8981cba7949f1ba924ef865d902df731627bc5998d14164063892fce"
2831dependencies = [
2832 "gix-actor",
2833 "gix-archive",
2834 "gix-attributes",
2835 "gix-blame",
2836 "gix-command",
2837 "gix-commitgraph",
2838 "gix-config",
2839 "gix-credentials",
2840 "gix-date",
2841 "gix-diff",
2842 "gix-dir",
2843 "gix-discover",
2844 "gix-error",
2845 "gix-features",
2846 "gix-filter",
2847 "gix-fs",
2848 "gix-glob",
2849 "gix-hash",
2850 "gix-hashtable",
2851 "gix-ignore",
2852 "gix-index",
2853 "gix-lock",
2854 "gix-mailmap",
2855 "gix-negotiate",
2856 "gix-object",
2857 "gix-odb",
2858 "gix-pack",
2859 "gix-path",
2860 "gix-pathspec",
2861 "gix-prompt",
2862 "gix-protocol",
2863 "gix-ref",
2864 "gix-refspec",
2865 "gix-revision",
2866 "gix-revwalk",
2867 "gix-sec",
2868 "gix-shallow",
2869 "gix-status",
2870 "gix-submodule",
2871 "gix-tempfile",
2872 "gix-trace",
2873 "gix-transport",
2874 "gix-traverse",
2875 "gix-url",
2876 "gix-utils",
2877 "gix-validate",
2878 "gix-worktree",
2879 "gix-worktree-state",
2880 "gix-worktree-stream",
2881 "gix-zlib",
2882 "nonempty",
2883 "parking_lot",
2884 "regex",
2885 "serde",
2886 "signal-hook",
2887 "smallvec",
2888 "thiserror 2.0.20",
2889]
2890
2891[[package]]
2892name = "gix-actor"
2893version = "0.41.2"
2894source = "registry+https://github.com/rust-lang/crates.io-index"
2895checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b"
2896dependencies = [
2897 "bstr",
2898 "gix-date",
2899 "gix-error",
2900 "serde",
2901]
2902
2903[[package]]
2904name = "gix-archive"
2905version = "0.35.0"
2906source = "registry+https://github.com/rust-lang/crates.io-index"
2907checksum = "6760ef6ef4edfa6449918977d59518ff209676f47e89bdce87a36a50be35e834"
2908dependencies = [
2909 "bstr",
2910 "gix-date",
2911 "gix-error",
2912 "gix-object",
2913 "gix-worktree-stream",
2914]
2915
2916[[package]]
2917name = "gix-attributes"
2918version = "0.34.0"
2919source = "registry+https://github.com/rust-lang/crates.io-index"
2920checksum = "c31c593692ebdc1e38858d9a2b56f6a594c501e24a38971fe6685571f5a07be0"
2921dependencies = [
2922 "bstr",
2923 "gix-features",
2924 "gix-glob",
2925 "gix-path",
2926 "gix-quote",
2927 "gix-trace",
2928 "serde",
2929 "smallvec",
2930 "thiserror 2.0.20",
2931 "unicode-bom",
2932]
2933
2934[[package]]
2935name = "gix-bitmap"
2936version = "0.3.3"
2937source = "registry+https://github.com/rust-lang/crates.io-index"
2938checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7"
2939dependencies = [
2940 "gix-error",
2941]
2942
2943[[package]]
2944name = "gix-blame"
2945version = "0.16.0"
2946source = "registry+https://github.com/rust-lang/crates.io-index"
2947checksum = "b06d20ff0e88ada2dd852b3852727b664ec9baefdf653d1d4e722e858c52cd17"
2948dependencies = [
2949 "gix-commitgraph",
2950 "gix-date",
2951 "gix-diff",
2952 "gix-error",
2953 "gix-hash",
2954 "gix-object",
2955 "gix-revwalk",
2956 "gix-trace",
2957 "gix-traverse",
2958 "gix-worktree",
2959 "smallvec",
2960 "thiserror 2.0.20",
2961]
2962
2963[[package]]
2964name = "gix-chunk"
2965version = "0.7.3"
2966source = "registry+https://github.com/rust-lang/crates.io-index"
2967checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc"
2968dependencies = [
2969 "gix-error",
2970]
2971
2972[[package]]
2973name = "gix-command"
2974version = "0.9.2"
2975source = "registry+https://github.com/rust-lang/crates.io-index"
2976checksum = "cf4363accdf6ef7ba861871d2d521ab7418a04aaaed919fadb022af71d379b12"
2977dependencies = [
2978 "bstr",
2979 "gix-path",
2980 "gix-quote",
2981 "gix-trace",
2982 "shell-words",
2983]
2984
2985[[package]]
2986name = "gix-commitgraph"
2987version = "0.38.0"
2988source = "registry+https://github.com/rust-lang/crates.io-index"
2989checksum = "a2cd7f054ae2727223fe46dd39c012f066b12f532962d336d29ee193261787da"
2990dependencies = [
2991 "bstr",
2992 "gix-chunk",
2993 "gix-error",
2994 "gix-hash",
2995 "memmap2",
2996 "nonempty",
2997 "serde",
2998]
2999
3000[[package]]
3001name = "gix-config"
3002version = "0.59.0"
3003source = "registry+https://github.com/rust-lang/crates.io-index"
3004checksum = "103d11bef95c467577ecfa8b7b86a22e65af3507b2c9bfa3809a4afbae7df301"
3005dependencies = [
3006 "bstr",
3007 "gix-config-value",
3008 "gix-features",
3009 "gix-glob",
3010 "gix-path",
3011 "gix-ref",
3012 "gix-sec",
3013 "gix-utils",
3014 "smallvec",
3015 "thiserror 2.0.20",
3016 "unicode-bom",
3017]
3018
3019[[package]]
3020name = "gix-config-value"
3021version = "0.19.1"
3022source = "registry+https://github.com/rust-lang/crates.io-index"
3023checksum = "6f6af5321bfd3711a279d6b244d58532ba1cfabf9eb6374791f19929d8970082"
3024dependencies = [
3025 "bitflags 2.13.1",
3026 "bstr",
3027 "gix-path",
3028 "libc",
3029 "thiserror 2.0.20",
3030]
3031
3032[[package]]
3033name = "gix-credentials"
3034version = "0.39.1"
3035source = "registry+https://github.com/rust-lang/crates.io-index"
3036checksum = "f9fbdb1c417980d05d547c19fc2b63344b5257c2387048d69ffe957bda142b59"
3037dependencies = [
3038 "bstr",
3039 "gix-command",
3040 "gix-config-value",
3041 "gix-date",
3042 "gix-path",
3043 "gix-prompt",
3044 "gix-quote",
3045 "gix-sec",
3046 "gix-trace",
3047 "gix-url",
3048 "serde",
3049 "thiserror 2.0.20",
3050]
3051
3052[[package]]
3053name = "gix-date"
3054version = "0.15.6"
3055source = "registry+https://github.com/rust-lang/crates.io-index"
3056checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567"
3057dependencies = [
3058 "bstr",
3059 "gix-error",
3060 "itoa",
3061 "jiff",
3062 "serde",
3063]
3064
3065[[package]]
3066name = "gix-diff"
3067version = "0.66.0"
3068source = "registry+https://github.com/rust-lang/crates.io-index"
3069checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc"
3070dependencies = [
3071 "bstr",
3072 "gix-attributes",
3073 "gix-command",
3074 "gix-filter",
3075 "gix-fs",
3076 "gix-hash",
3077 "gix-imara-diff",
3078 "gix-index",
3079 "gix-object",
3080 "gix-path",
3081 "gix-pathspec",
3082 "gix-tempfile",
3083 "gix-trace",
3084 "gix-traverse",
3085 "gix-worktree",
3086 "thiserror 2.0.20",
3087]
3088
3089[[package]]
3090name = "gix-dir"
3091version = "0.28.0"
3092source = "registry+https://github.com/rust-lang/crates.io-index"
3093checksum = "f24bc78f283946ac64757c8a61ffa71f0230aa1e8d98cbb0771db479871dd5b6"
3094dependencies = [
3095 "bstr",
3096 "gix-discover",
3097 "gix-fs",
3098 "gix-ignore",
3099 "gix-index",
3100 "gix-object",
3101 "gix-path",
3102 "gix-pathspec",
3103 "gix-trace",
3104 "gix-utils",
3105 "gix-worktree",
3106 "thiserror 2.0.20",
3107]
3108
3109[[package]]
3110name = "gix-discover"
3111version = "0.54.0"
3112source = "registry+https://github.com/rust-lang/crates.io-index"
3113checksum = "b9f517766fa1101dfe2606c1a19a8ffa699099030995a9194445446dfe261bdf"
3114dependencies = [
3115 "bstr",
3116 "dunce",
3117 "gix-fs",
3118 "gix-path",
3119 "gix-ref",
3120 "gix-sec",
3121 "thiserror 2.0.20",
3122]
3123
3124[[package]]
3125name = "gix-error"
3126version = "0.2.5"
3127source = "registry+https://github.com/rust-lang/crates.io-index"
3128checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329"
3129dependencies = [
3130 "bstr",
3131]
3132
3133[[package]]
3134name = "gix-features"
3135version = "0.49.1"
3136source = "registry+https://github.com/rust-lang/crates.io-index"
3137checksum = "39c0e59d9d253dcccc38c3a46b91bfb9b46bd63eed54fe1a719e12194884d52a"
3138dependencies = [
3139 "bytes",
3140 "bytesize",
3141 "crc32fast",
3142 "crossbeam-channel",
3143 "gix-path",
3144 "gix-trace",
3145 "gix-utils",
3146 "libc",
3147 "once_cell",
3148 "parking_lot",
3149 "prodash",
3150 "walkdir",
3151]
3152
3153[[package]]
3154name = "gix-filter"
3155version = "0.33.0"
3156source = "registry+https://github.com/rust-lang/crates.io-index"
3157checksum = "5e7b5dbf524d97e839f642930c76d7f011c0791e7d11d8148989ac5af7c76aa8"
3158dependencies = [
3159 "bstr",
3160 "encoding_rs",
3161 "gix-attributes",
3162 "gix-command",
3163 "gix-hash",
3164 "gix-object",
3165 "gix-packetline",
3166 "gix-path",
3167 "gix-quote",
3168 "gix-trace",
3169 "gix-utils",
3170 "smallvec",
3171 "thiserror 2.0.20",
3172]
3173
3174[[package]]
3175name = "gix-fs"
3176version = "0.22.1"
3177source = "registry+https://github.com/rust-lang/crates.io-index"
3178checksum = "ebcfa9fd253f25350a3b21b3dd74034a446098e373c6123d4cee3519894f12ef"
3179dependencies = [
3180 "bstr",
3181 "gix-features",
3182 "gix-path",
3183 "gix-utils",
3184 "thiserror 2.0.20",
3185]
3186
3187[[package]]
3188name = "gix-glob"
3189version = "0.27.1"
3190source = "registry+https://github.com/rust-lang/crates.io-index"
3191checksum = "b417cf515fd8c91468b578071f76d6cba716f8a1eccd853906bff4908b2c1413"
3192dependencies = [
3193 "bitflags 2.13.1",
3194 "bstr",
3195 "gix-features",
3196 "gix-path",
3197 "serde",
3198]
3199
3200[[package]]
3201name = "gix-hash"
3202version = "0.26.2"
3203source = "registry+https://github.com/rust-lang/crates.io-index"
3204checksum = "380b9c423a54f0821064b954b5a5ab25c660812f6a91da03c3f1cb4b10762aa5"
3205dependencies = [
3206 "faster-hex",
3207 "gix-features",
3208 "serde",
3209 "sha1-checked",
3210 "thiserror 2.0.20",
3211]
3212
3213[[package]]
3214name = "gix-hashtable"
3215version = "0.16.0"
3216source = "registry+https://github.com/rust-lang/crates.io-index"
3217checksum = "78fccd6fea3bcf0b39c076bae60ae49b08daaf538b950202101a981f9d3c01d3"
3218dependencies = [
3219 "gix-hash",
3220 "hashbrown 0.17.1",
3221 "parking_lot",
3222]
3223
3224[[package]]
3225name = "gix-ignore"
3226version = "0.22.1"
3227source = "registry+https://github.com/rust-lang/crates.io-index"
3228checksum = "65859a2f7de5e159d4344a5486ebf53b6bac22d5ec6afa836e47c10ae88a7f0f"
3229dependencies = [
3230 "bstr",
3231 "gix-glob",
3232 "gix-path",
3233 "gix-trace",
3234 "serde",
3235 "unicode-bom",
3236]
3237
3238[[package]]
3239name = "gix-imara-diff"
3240version = "0.2.5"
3241source = "registry+https://github.com/rust-lang/crates.io-index"
3242checksum = "1c91d8cffac8849493a82233811bd02b2b183b8cf39bf704de0fa0841b737595"
3243dependencies = [
3244 "bstr",
3245 "hashbrown 0.17.1",
3246]
3247
3248[[package]]
3249name = "gix-index"
3250version = "0.54.0"
3251source = "registry+https://github.com/rust-lang/crates.io-index"
3252checksum = "5009c4e7e9f9b4cfaaab1153e49133eb04d79c015b5702d6c3d2ab94271a89c6"
3253dependencies = [
3254 "bitflags 2.13.1",
3255 "bstr",
3256 "filetime",
3257 "fnv",
3258 "gix-bitmap",
3259 "gix-features",
3260 "gix-fs",
3261 "gix-hash",
3262 "gix-lock",
3263 "gix-object",
3264 "gix-traverse",
3265 "gix-utils",
3266 "gix-validate",
3267 "hashbrown 0.17.1",
3268 "itoa",
3269 "libc",
3270 "memmap2",
3271 "rustix",
3272 "serde",
3273 "smallvec",
3274 "thiserror 2.0.20",
3275]
3276
3277[[package]]
3278name = "gix-lock"
3279version = "24.0.0"
3280source = "registry+https://github.com/rust-lang/crates.io-index"
3281checksum = "d4c69157820343bf1c6e4b88b9808e920900de02e18aaf5862b30ada43814848"
3282dependencies = [
3283 "gix-tempfile",
3284 "gix-utils",
3285 "thiserror 2.0.20",
3286]
3287
3288[[package]]
3289name = "gix-mailmap"
3290version = "0.33.2"
3291source = "registry+https://github.com/rust-lang/crates.io-index"
3292checksum = "a824767d38b81475059cb01f5020a13fb96e7ed6bbf9851c7112b46ada78db48"
3293dependencies = [
3294 "bstr",
3295 "gix-actor",
3296 "gix-date",
3297 "gix-error",
3298 "serde",
3299]
3300
3301[[package]]
3302name = "gix-negotiate"
3303version = "0.34.0"
3304source = "registry+https://github.com/rust-lang/crates.io-index"
3305checksum = "2fa5aa789990f124e4f559b142cecfb60662cb4ae17006684ea9d668f4f07689"
3306dependencies = [
3307 "bitflags 2.13.1",
3308 "gix-commitgraph",
3309 "gix-date",
3310 "gix-hash",
3311 "gix-object",
3312 "gix-revwalk",
3313]
3314
3315[[package]]
3316name = "gix-object"
3317version = "0.63.0"
3318source = "registry+https://github.com/rust-lang/crates.io-index"
3319checksum = "0e48c235e7f886eb819fc878af75be889333dd3c38bee02ed7af48ae2cf596c4"
3320dependencies = [
3321 "bstr",
3322 "gix-actor",
3323 "gix-date",
3324 "gix-features",
3325 "gix-hash",
3326 "gix-hashtable",
3327 "gix-utils",
3328 "gix-validate",
3329 "itoa",
3330 "serde",
3331 "smallvec",
3332 "thiserror 2.0.20",
3333]
3334
3335[[package]]
3336name = "gix-odb"
3337version = "0.83.0"
3338source = "registry+https://github.com/rust-lang/crates.io-index"
3339checksum = "8dd494ffb5037e62b8220109e894d2861ff2150a2cacbfccdba57ae1ebab2b96"
3340dependencies = [
3341 "arc-swap",
3342 "gix-features",
3343 "gix-fs",
3344 "gix-hash",
3345 "gix-hashtable",
3346 "gix-object",
3347 "gix-pack",
3348 "gix-path",
3349 "gix-quote",
3350 "gix-zlib",
3351 "memmap2",
3352 "parking_lot",
3353 "serde",
3354 "tempfile",
3355 "thiserror 2.0.20",
3356]
3357
3358[[package]]
3359name = "gix-pack"
3360version = "0.73.0"
3361source = "registry+https://github.com/rust-lang/crates.io-index"
3362checksum = "6d5446127b269706e85998065267ddd2ccc3550179da6780b22fe496175ccb20"
3363dependencies = [
3364 "clru",
3365 "gix-chunk",
3366 "gix-error",
3367 "gix-features",
3368 "gix-hash",
3369 "gix-hashtable",
3370 "gix-object",
3371 "gix-path",
3372 "gix-zlib",
3373 "memmap2",
3374 "serde",
3375 "smallvec",
3376 "thiserror 2.0.20",
3377 "uluru",
3378]
3379
3380[[package]]
3381name = "gix-packetline"
3382version = "0.22.2"
3383source = "registry+https://github.com/rust-lang/crates.io-index"
3384checksum = "792bd92bea390087e6223b18d4d023decc36467de31e6b066b69b94beea1b9e2"
3385dependencies = [
3386 "bstr",
3387 "faster-hex",
3388 "gix-trace",
3389 "thiserror 2.0.20",
3390]
3391
3392[[package]]
3393name = "gix-path"
3394version = "0.12.6"
3395source = "registry+https://github.com/rust-lang/crates.io-index"
3396checksum = "38fc6f029ea67de83cbcbd33fd98c05a48360d2932b39d9fdacbc6eae802d475"
3397dependencies = [
3398 "bstr",
3399 "gix-trace",
3400 "gix-validate",
3401 "thiserror 2.0.20",
3402]
3403
3404[[package]]
3405name = "gix-pathspec"
3406version = "0.19.0"
3407source = "registry+https://github.com/rust-lang/crates.io-index"
3408checksum = "49f6fa5f8007f008187c3f60b4373209ca83d1cc947f35ede03e16cd15a4d137"
3409dependencies = [
3410 "bitflags 2.13.1",
3411 "bstr",
3412 "gix-attributes",
3413 "gix-config-value",
3414 "gix-glob",
3415 "gix-path",
3416 "thiserror 2.0.20",
3417]
3418
3419[[package]]
3420name = "gix-prompt"
3421version = "0.16.0"
3422source = "registry+https://github.com/rust-lang/crates.io-index"
3423checksum = "5cb1f1eb92d6f4c9d4c105a6ca912cff637fbd5cacbcbafa5deccd88bfaa3565"
3424dependencies = [
3425 "gix-command",
3426 "gix-config-value",
3427 "parking_lot",
3428 "rustix",
3429 "thiserror 2.0.20",
3430]
3431
3432[[package]]
3433name = "gix-protocol"
3434version = "0.64.0"
3435source = "registry+https://github.com/rust-lang/crates.io-index"
3436checksum = "dede40e89c1e90f548415f50636bb051f6d9c60f68b8b710bc07825722d19588"
3437dependencies = [
3438 "bisync",
3439 "bstr",
3440 "gix-date",
3441 "gix-features",
3442 "gix-hash",
3443 "gix-ref",
3444 "gix-shallow",
3445 "gix-transport",
3446 "gix-utils",
3447 "nonempty",
3448 "serde",
3449 "thiserror 2.0.20",
3450]
3451
3452[[package]]
3453name = "gix-quote"
3454version = "0.7.2"
3455source = "registry+https://github.com/rust-lang/crates.io-index"
3456checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef"
3457dependencies = [
3458 "bstr",
3459 "gix-error",
3460 "gix-utils",
3461]
3462
3463[[package]]
3464name = "gix-ref"
3465version = "0.66.0"
3466source = "registry+https://github.com/rust-lang/crates.io-index"
3467checksum = "eeb0c90a8f6202ceaaa22996cbf837c943ccb2d8af9ff3490f0758305e6b7883"
3468dependencies = [
3469 "gix-actor",
3470 "gix-features",
3471 "gix-fs",
3472 "gix-hash",
3473 "gix-lock",
3474 "gix-object",
3475 "gix-path",
3476 "gix-tempfile",
3477 "gix-utils",
3478 "gix-validate",
3479 "memmap2",
3480 "serde",
3481 "thiserror 2.0.20",
3482]
3483
3484[[package]]
3485name = "gix-refspec"
3486version = "0.44.0"
3487source = "registry+https://github.com/rust-lang/crates.io-index"
3488checksum = "7406282cc0259b51f6aee299ca3d31279a020530363152a2e6c96e8a7f7bbc83"
3489dependencies = [
3490 "bstr",
3491 "gix-error",
3492 "gix-glob",
3493 "gix-hash",
3494 "gix-revision",
3495 "gix-validate",
3496 "smallvec",
3497 "thiserror 2.0.20",
3498]
3499
3500[[package]]
3501name = "gix-revision"
3502version = "0.48.0"
3503source = "registry+https://github.com/rust-lang/crates.io-index"
3504checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681"
3505dependencies = [
3506 "bitflags 2.13.1",
3507 "bstr",
3508 "gix-commitgraph",
3509 "gix-date",
3510 "gix-error",
3511 "gix-hash",
3512 "gix-hashtable",
3513 "gix-object",
3514 "gix-revwalk",
3515 "gix-trace",
3516 "nonempty",
3517 "serde",
3518]
3519
3520[[package]]
3521name = "gix-revwalk"
3522version = "0.34.0"
3523source = "registry+https://github.com/rust-lang/crates.io-index"
3524checksum = "36c113c0a53294dc6280ffc06cbcc4f50f820397e97d6a00b429a44b8db26e29"
3525dependencies = [
3526 "gix-commitgraph",
3527 "gix-date",
3528 "gix-error",
3529 "gix-hash",
3530 "gix-hashtable",
3531 "gix-object",
3532 "smallvec",
3533 "thiserror 2.0.20",
3534]
3535
3536[[package]]
3537name = "gix-sec"
3538version = "0.14.2"
3539source = "registry+https://github.com/rust-lang/crates.io-index"
3540checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f"
3541dependencies = [
3542 "bitflags 2.13.1",
3543 "gix-path",
3544 "libc",
3545 "serde",
3546 "windows-sys 0.61.2",
3547]
3548
3549[[package]]
3550name = "gix-shallow"
3551version = "0.13.0"
3552source = "registry+https://github.com/rust-lang/crates.io-index"
3553checksum = "1ecc9f4b40537043e4bbd7d3d1760e74fb8e7b07a546166b558acaa73ad97f4a"
3554dependencies = [
3555 "bstr",
3556 "gix-hash",
3557 "gix-lock",
3558 "nonempty",
3559 "serde",
3560 "thiserror 2.0.20",
3561]
3562
3563[[package]]
3564name = "gix-status"
3565version = "0.33.0"
3566source = "registry+https://github.com/rust-lang/crates.io-index"
3567checksum = "5f83b1c74e69b90411fbfe89ccebc47aa49457ce4eb9b942b1311258fc863d22"
3568dependencies = [
3569 "bstr",
3570 "filetime",
3571 "gix-diff",
3572 "gix-dir",
3573 "gix-features",
3574 "gix-filter",
3575 "gix-fs",
3576 "gix-hash",
3577 "gix-index",
3578 "gix-object",
3579 "gix-path",
3580 "gix-pathspec",
3581 "gix-worktree",
3582 "hashbrown 0.16.1",
3583 "portable-atomic",
3584 "thiserror 2.0.20",
3585 "windows-sys 0.61.2",
3586]
3587
3588[[package]]
3589name = "gix-submodule"
3590version = "0.33.0"
3591source = "registry+https://github.com/rust-lang/crates.io-index"
3592checksum = "5fd98077a56d08886112e6b08dc94076d03539f4bc0b9d7880e4be2b8a640d8c"
3593dependencies = [
3594 "bstr",
3595 "gix-config",
3596 "gix-path",
3597 "gix-pathspec",
3598 "gix-refspec",
3599 "gix-url",
3600 "thiserror 2.0.20",
3601]
3602
3603[[package]]
3604name = "gix-tempfile"
3605version = "24.0.0"
3606source = "registry+https://github.com/rust-lang/crates.io-index"
3607checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e"
3608dependencies = [
3609 "dashmap",
3610 "gix-fs",
3611 "libc",
3612 "parking_lot",
3613 "signal-hook",
3614 "signal-hook-registry",
3615 "tempfile",
3616]
3617
3618[[package]]
3619name = "gix-trace"
3620version = "0.1.21"
3621source = "registry+https://github.com/rust-lang/crates.io-index"
3622checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814"
3623
3624[[package]]
3625name = "gix-transport"
3626version = "0.58.1"
3627source = "registry+https://github.com/rust-lang/crates.io-index"
3628checksum = "3f36d045b840f8aeee1a527e677eab1fbebfbbe94bf2e708fa81d0b4b742d5fc"
3629dependencies = [
3630 "bstr",
3631 "gix-command",
3632 "gix-features",
3633 "gix-packetline",
3634 "gix-path",
3635 "gix-quote",
3636 "gix-sec",
3637 "gix-url",
3638 "serde",
3639 "thiserror 2.0.20",
3640]
3641
3642[[package]]
3643name = "gix-traverse"
3644version = "0.60.0"
3645source = "registry+https://github.com/rust-lang/crates.io-index"
3646checksum = "008c5cd879e46e86b5c2469e633611978b18775d53d05668d691bc13088bd409"
3647dependencies = [
3648 "bitflags 2.13.1",
3649 "gix-commitgraph",
3650 "gix-date",
3651 "gix-hash",
3652 "gix-hashtable",
3653 "gix-object",
3654 "gix-revwalk",
3655 "smallvec",
3656 "thiserror 2.0.20",
3657]
3658
3659[[package]]
3660name = "gix-url"
3661version = "0.37.1"
3662source = "registry+https://github.com/rust-lang/crates.io-index"
3663checksum = "31bdfc93aa880cda3272718a5879ce3aa7723fa13514320dd6608151607afe72"
3664dependencies = [
3665 "bstr",
3666 "gix-path",
3667 "gix-utils",
3668 "percent-encoding 2.3.2",
3669 "serde",
3670 "thiserror 2.0.20",
3671]
3672
3673[[package]]
3674name = "gix-utils"
3675version = "0.3.6"
3676source = "registry+https://github.com/rust-lang/crates.io-index"
3677checksum = "0da1c46491b49458a446cc76f0085860f8164c2290742e0aa8c653ce67240a97"
3678dependencies = [
3679 "bstr",
3680 "fastrand",
3681 "getrandom 0.4.3",
3682 "unicode-normalization",
3683]
3684
3685[[package]]
3686name = "gix-validate"
3687version = "0.11.4"
3688source = "registry+https://github.com/rust-lang/crates.io-index"
3689checksum = "4dae8780f63ed8a803b8bdabbd7aa5f5c5d74592c8b50eed875c1bb4f6545a6a"
3690dependencies = [
3691 "bstr",
3692]
3693
3694[[package]]
3695name = "gix-worktree"
3696version = "0.55.0"
3697source = "registry+https://github.com/rust-lang/crates.io-index"
3698checksum = "31eb8e675122e83585e461fe28f68ff8c5ed55b49017b697e7e76423ff973424"
3699dependencies = [
3700 "bstr",
3701 "gix-attributes",
3702 "gix-features",
3703 "gix-fs",
3704 "gix-glob",
3705 "gix-hash",
3706 "gix-ignore",
3707 "gix-index",
3708 "gix-object",
3709 "gix-path",
3710 "gix-validate",
3711 "serde",
3712]
3713
3714[[package]]
3715name = "gix-worktree-state"
3716version = "0.33.0"
3717source = "registry+https://github.com/rust-lang/crates.io-index"
3718checksum = "bc47372fc12b9fbea51b257bcc8d4970326cf5c7e42135bbfb5d72871bb30bd4"
3719dependencies = [
3720 "bstr",
3721 "gix-features",
3722 "gix-filter",
3723 "gix-fs",
3724 "gix-index",
3725 "gix-object",
3726 "gix-path",
3727 "gix-worktree",
3728 "io-close",
3729 "thiserror 2.0.20",
3730]
3731
3732[[package]]
3733name = "gix-worktree-stream"
3734version = "0.35.0"
3735source = "registry+https://github.com/rust-lang/crates.io-index"
3736checksum = "3b088c8724e7be120c4798dd86925cf05332c9d356a463542578600c50c7a549"
3737dependencies = [
3738 "gix-attributes",
3739 "gix-error",
3740 "gix-features",
3741 "gix-filter",
3742 "gix-fs",
3743 "gix-hash",
3744 "gix-object",
3745 "gix-path",
3746 "gix-traverse",
3747 "parking_lot",
3748]
3749
3750[[package]]
3751name = "gix-zlib"
3752version = "0.1.0"
3753source = "registry+https://github.com/rust-lang/crates.io-index"
3754checksum = "3e8813f5579b3075ff9c90f7c59cd2b62b4ebb639361f0911648b22d7446cc7c"
3755dependencies = [
3756 "serde",
3757 "thiserror 2.0.20",
3758 "zlib-rs",
3759]
3760
3761[[package]]
3762name = "glifo"
3763version = "0.1.1"
3764source = "registry+https://github.com/rust-lang/crates.io-index"
3765checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
3766dependencies = [
37672108 "bytemuck",
37682109 "foldhash",
37692110 "hashbrown 0.17.1",
@@ -3794,23 +2135,12 @@
37942135source = "registry+https://github.com/rust-lang/crates.io-index"
37952136checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
37962137dependencies = [
3797 "ff 0.13.1",
2138 "ff",
37982139 "rand_core 0.6.4",
37992140 "subtle",
38002141]
38012142
38022143[[package]]
3803name = "group"
3804version = "0.14.0"
3805source = "registry+https://github.com/rust-lang/crates.io-index"
3806checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4"
3807dependencies = [
3808 "ff 0.14.0",
3809 "rand_core 0.10.1",
3810 "subtle",
3811]
3812
3813[[package]]
38142144name = "guillotiere"
38152145version = "0.7.0"
38162146source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3855,43 +2185,18 @@
38552185 "tokio",
38562186 "tokio-util",
38572187 "tracing",
3858]
3859
3860[[package]]
3861name = "half"
3862version = "2.7.1"
3863source = "registry+https://github.com/rust-lang/crates.io-index"
3864checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
3865dependencies = [
3866 "cfg-if",
3867 "crunchy",
3868 "num-traits",
3869 "zerocopy",
3870]
3871
3872[[package]]
3873name = "handlebars"
3874version = "6.4.4"
3875source = "registry+https://github.com/rust-lang/crates.io-index"
3876checksum = "75c54236f9045c8004a77942bebc52145b4844639db934a5c70fe08617fbe61a"
3877dependencies = [
3878 "derive_builder",
3879 "log 0.4.33",
3880 "num-order",
3881 "pest",
3882 "pest_derive",
3883 "serde",
3884 "serde_json",
3885 "thiserror 2.0.20",
38862188]
38872189
38882190[[package]]
3889name = "hash32"
3890version = "0.3.1"
2191name = "half"
2192version = "2.7.1"
38912193source = "registry+https://github.com/rust-lang/crates.io-index"
3892checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
2194checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
38932195dependencies = [
3894 "byteorder",
2196 "cfg-if",
2197 "crunchy",
2198 "num-traits",
2199 "zerocopy",
38952200]
38962201
38972202[[package]]
@@ -3905,12 +2210,6 @@
39052210
39062211[[package]]
39072212name = "hashbrown"
3908version = "0.14.5"
3909source = "registry+https://github.com/rust-lang/crates.io-index"
3910checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
3911
3912[[package]]
3913name = "hashbrown"
39142213version = "0.16.1"
39152214source = "registry+https://github.com/rust-lang/crates.io-index"
39162215checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
@@ -3926,8 +2225,6 @@
39262225source = "registry+https://github.com/rust-lang/crates.io-index"
39272226checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
39282227dependencies = [
3929 "allocator-api2",
3930 "equivalent",
39312228 "foldhash",
39322229]
39332230
@@ -4031,16 +2328,6 @@
40312328]
40322329
40332330[[package]]
4034name = "heapless"
4035version = "0.8.0"
4036source = "registry+https://github.com/rust-lang/crates.io-index"
4037checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
4038dependencies = [
4039 "hash32",
4040 "stable_deref_trait",
4041]
4042
4043[[package]]
40442331name = "heck"
40452332version = "0.4.1"
40462333source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4065,12 +2352,6 @@
40652352checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
40662353
40672354[[package]]
4068name = "hex-literal"
4069version = "1.1.0"
4070source = "registry+https://github.com/rust-lang/crates.io-index"
4071checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1"
4072
4073[[package]]
40742355name = "hkdf"
40752356version = "0.12.4"
40762357source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4198,21 +2479,13 @@
41982479checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
41992480
42002481[[package]]
4201name = "human_format"
4202version = "1.2.1"
4203source = "registry+https://github.com/rust-lang/crates.io-index"
4204checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b"
4205
4206[[package]]
42072482name = "hybrid-array"
42082483version = "0.4.14"
42092484source = "registry+https://github.com/rust-lang/crates.io-index"
42102485checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
42112486dependencies = [
42122487 "ctutils",
4213 "subtle",
42142488 "typenum",
4215 "zeroize",
42162489]
42172490
42182491[[package]]
@@ -4588,6 +2861,7 @@
45882861 "export",
45892862 "git",
45902863 "jwt",
2864 "kenshell",
45912865 "models",
45922866 "reqwest 0.12.28",
45932867 "sea-orm",
@@ -4626,7 +2900,7 @@
46262900source = "registry+https://github.com/rust-lang/crates.io-index"
46272901checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
46282902dependencies = [
4629 "generic-array 0.14.9",
2903 "generic-array",
46302904]
46312905
46322906[[package]]
@@ -4635,23 +2909,10 @@
46352909source = "registry+https://github.com/rust-lang/crates.io-index"
46362910checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
46372911dependencies = [
4638 "block-padding",
46392912 "hybrid-array",
46402913]
46412914
46422915[[package]]
4643name = "internal-russh-num-bigint"
4644version = "0.5.0"
4645source = "registry+https://github.com/rust-lang/crates.io-index"
4646checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8"
4647dependencies = [
4648 "num-integer",
4649 "num-traits",
4650 "rand 0.10.2",
4651 "rand_core 0.10.1",
4652]
4653
4654[[package]]
46552916name = "inventory"
46562917version = "0.3.24"
46572918source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4661,27 +2922,6 @@
46612922]
46622923
46632924[[package]]
4664name = "io-close"
4665version = "0.3.7"
4666source = "registry+https://github.com/rust-lang/crates.io-index"
4667checksum = "9cadcf447f06744f8ce713d2d6239bb5bde2c357a452397a9ed90c625da390bc"
4668dependencies = [
4669 "libc",
4670 "winapi",
4671]
4672
4673[[package]]
4674name = "io-uring"
4675version = "0.7.14"
4676source = "registry+https://github.com/rust-lang/crates.io-index"
4677checksum = "d64d8ca234d152948ceaede1f419b6a83983a5ecccaac05fb337a809c96d3aa6"
4678dependencies = [
4679 "bitflags 2.13.1",
4680 "cfg-if",
4681 "libc",
4682]
4683
4684[[package]]
46852925name = "ipnet"
46862926version = "2.12.1"
46872927source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4739,72 +2979,10 @@
47392979]
47402980
47412981[[package]]
4742name = "itertools"
4743version = "0.15.0"
4744source = "registry+https://github.com/rust-lang/crates.io-index"
4745checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
4746dependencies = [
4747 "either",
4748]
4749
4750[[package]]
47512982name = "itoa"
47522983version = "1.0.18"
47532984source = "registry+https://github.com/rust-lang/crates.io-index"
47542985checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
4755
4756[[package]]
4757name = "jiff"
4758version = "0.2.35"
4759source = "registry+https://github.com/rust-lang/crates.io-index"
4760checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
4761dependencies = [
4762 "defmt",
4763 "jiff-core",
4764 "jiff-static",
4765 "jiff-tzdb-platform",
4766 "log 0.4.33",
4767 "portable-atomic",
4768 "portable-atomic-util",
4769 "serde_core",
4770 "windows-link",
4771]
4772
4773[[package]]
4774name = "jiff-core"
4775version = "0.1.0"
4776source = "registry+https://github.com/rust-lang/crates.io-index"
4777checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
4778dependencies = [
4779 "defmt",
4780]
4781
4782[[package]]
4783name = "jiff-static"
4784version = "0.2.35"
4785source = "registry+https://github.com/rust-lang/crates.io-index"
4786checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
4787dependencies = [
4788 "jiff-core",
4789 "proc-macro2",
4790 "quote",
4791 "syn 2.0.119",
4792]
4793
4794[[package]]
4795name = "jiff-tzdb"
4796version = "0.1.8"
4797source = "registry+https://github.com/rust-lang/crates.io-index"
4798checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
4799
4800[[package]]
4801name = "jiff-tzdb-platform"
4802version = "0.1.3"
4803source = "registry+https://github.com/rust-lang/crates.io-index"
4804checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
4805dependencies = [
4806 "jiff-tzdb",
4807]
48082986
48092987[[package]]
48102988name = "jni"
@@ -4934,8 +3112,8 @@
49343112 "hmac-sha256",
49353113 "hmac-sha512",
49363114 "k256",
4937 "p256 0.13.2",
4938 "p384 0.13.1",
3115 "p256",
3116 "p384",
49393117 "rand 0.8.7",
49403118 "serde",
49413119 "serde_json",
@@ -4951,8 +3129,8 @@
49513129checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
49523130dependencies = [
49533131 "cfg-if",
4954 "ecdsa 0.16.9",
4955 "elliptic-curve 0.13.8",
3132 "ecdsa",
3133 "elliptic-curve",
49563134 "once_cell",
49573135 "sha2 0.10.9",
49583136 "signature 2.2.0",
@@ -4969,26 +3147,31 @@
49693147]
49703148
49713149[[package]]
4972name = "kem"
4973version = "0.3.0"
4974source = "registry+https://github.com/rust-lang/crates.io-index"
4975checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd"
3150name = "kenauth"
3151version = "1.0.0"
49763152dependencies = [
4977 "crypto-common 0.2.2",
4978 "rand_core 0.10.1",
3153 "config",
3154 "prost 0.14.4",
3155 "prost-types 0.14.1",
3156 "thiserror 2.0.20",
3157 "tokio",
3158 "tonic",
3159 "tonic-health",
3160 "tonic-prost",
3161 "tonic-prost-build",
3162 "tracing",
3163 "uuid",
49793164]
49803165
49813166[[package]]
4982name = "kenauth"
3167name = "kenshell"
49833168version = "1.0.0"
49843169dependencies = [
4985 "config",
49863170 "prost 0.14.4",
49873171 "prost-types 0.14.1",
49883172 "thiserror 2.0.20",
49893173 "tokio",
49903174 "tonic",
4991 "tonic-health",
49923175 "tonic-prost",
49933176 "tonic-prost-build",
49943177 "tracing",
@@ -5267,25 +3450,6 @@
52673450checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
52683451
52693452[[package]]
5270name = "lz4"
5271version = "1.28.1"
5272source = "registry+https://github.com/rust-lang/crates.io-index"
5273checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4"
5274dependencies = [
5275 "lz4-sys",
5276]
5277
5278[[package]]
5279name = "lz4-sys"
5280version = "1.11.1+lz4-1.10.0"
5281source = "registry+https://github.com/rust-lang/crates.io-index"
5282checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6"
5283dependencies = [
5284 "cc",
5285 "libc",
5286]
5287
5288[[package]]
52893453name = "mac_address"
52903454version = "1.1.8"
52913455source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5358,37 +3522,12 @@
53583522checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
53593523
53603524[[package]]
5361name = "md5"
5362version = "0.8.1"
5363source = "registry+https://github.com/rust-lang/crates.io-index"
5364checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
5365
5366[[package]]
5367name = "mea"
5368version = "0.6.7"
5369source = "registry+https://github.com/rust-lang/crates.io-index"
5370checksum = "c709842c4ce65cb91e2666ad5319dfc1efc3af0d34f02075eddca9000d9f8afb"
5371dependencies = [
5372 "hashbrown 0.17.1",
5373 "slab",
5374]
5375
5376[[package]]
53773525name = "memchr"
53783526version = "2.8.3"
53793527source = "registry+https://github.com/rust-lang/crates.io-index"
53803528checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
53813529
53823530[[package]]
5383name = "memmap2"
5384version = "0.9.11"
5385source = "registry+https://github.com/rust-lang/crates.io-index"
5386checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
5387dependencies = [
5388 "libc",
5389]
5390
5391[[package]]
53923531name = "memoffset"
53933532version = "0.9.1"
53943533source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5466,16 +3605,6 @@
54663605]
54673606
54683607[[package]]
5469name = "mixtrics"
5470version = "0.2.5"
5471source = "registry+https://github.com/rust-lang/crates.io-index"
5472checksum = "2c46b5adfb7a3ae4996d327a5bdc90e78fec025806dd312bdbe6f07a755e0ec9"
5473dependencies = [
5474 "itertools 0.15.0",
5475 "parking_lot",
5476]
5477
5478[[package]]
54793608name = "ml-dsa"
54803609version = "0.1.1"
54813610source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5492,20 +3621,6 @@
54923621]
54933622
54943623[[package]]
5495name = "ml-kem"
5496version = "0.3.2"
5497source = "registry+https://github.com/rust-lang/crates.io-index"
5498checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66"
5499dependencies = [
5500 "hybrid-array",
5501 "kem",
5502 "module-lattice",
5503 "pkcs8 0.11.0",
5504 "rand_core 0.10.1",
5505 "sha3 0.11.0",
5506]
5507
5508[[package]]
55093624name = "models"
55103625version = "1.0.0"
55113626dependencies = [
@@ -5540,23 +3655,6 @@
55403655]
55413656
55423657[[package]]
5543name = "multer"
5544version = "3.1.0"
5545source = "registry+https://github.com/rust-lang/crates.io-index"
5546checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
5547dependencies = [
5548 "bytes",
5549 "encoding_rs",
5550 "futures-util",
5551 "http 1.5.0",
5552 "httparse",
5553 "memchr",
5554 "mime 0.3.17",
5555 "spin",
5556 "version_check 0.9.5",
5557]
5558
5559[[package]]
55603658name = "multimap"
55613659version = "0.10.1"
55623660source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5605,15 +3703,6 @@
56053703]
56063704
56073705[[package]]
5608name = "nonempty"
5609version = "0.12.0"
5610source = "registry+https://github.com/rust-lang/crates.io-index"
5611checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6"
5612dependencies = [
5613 "serde",
5614]
5615
5616[[package]]
56173706name = "nu-ansi-term"
56183707version = "0.50.3"
56193708source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5690,21 +3779,6 @@
56903779dependencies = [
56913780 "num-integer",
56923781 "num-traits",
5693]
5694
5695[[package]]
5696name = "num-modular"
5697version = "0.6.5"
5698source = "registry+https://github.com/rust-lang/crates.io-index"
5699checksum = "bd8e500409e6cd603b03e477c26a6caecdc27ac58979a53e881c75eafc079f44"
5700
5701[[package]]
5702name = "num-order"
5703version = "1.2.0"
5704source = "registry+https://github.com/rust-lang/crates.io-index"
5705checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6"
5706dependencies = [
5707 "num-modular",
57083782]
57093783
57103784[[package]]
@@ -6098,23 +4172,10 @@
60984172source = "registry+https://github.com/rust-lang/crates.io-index"
60994173checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
61004174dependencies = [
6101 "ecdsa 0.16.9",
6102 "elliptic-curve 0.13.8",
6103 "primeorder 0.13.6",
4175 "ecdsa",
4176 "elliptic-curve",
4177 "primeorder",
61044178 "sha2 0.10.9",
6105]
6106
6107[[package]]
6108name = "p256"
6109version = "0.14.0"
6110source = "registry+https://github.com/rust-lang/crates.io-index"
6111checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a"
6112dependencies = [
6113 "ecdsa 0.17.0",
6114 "elliptic-curve 0.14.1",
6115 "primefield",
6116 "primeorder 0.14.0",
6117 "sha2 0.11.0",
61184179]
61194180
61204181[[package]]
@@ -6123,61 +4184,13 @@
61234184source = "registry+https://github.com/rust-lang/crates.io-index"
61244185checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
61254186dependencies = [
6126 "ecdsa 0.16.9",
6127 "elliptic-curve 0.13.8",
6128 "primeorder 0.13.6",
4187 "ecdsa",
4188 "elliptic-curve",
4189 "primeorder",
61294190 "sha2 0.10.9",
61304191]
61314192
61324193[[package]]
6133name = "p384"
6134version = "0.14.0"
6135source = "registry+https://github.com/rust-lang/crates.io-index"
6136checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f"
6137dependencies = [
6138 "ecdsa 0.17.0",
6139 "elliptic-curve 0.14.1",
6140 "fiat-crypto",
6141 "primefield",
6142 "primeorder 0.14.0",
6143 "sha2 0.11.0",
6144]
6145
6146[[package]]
6147name = "p521"
6148version = "0.14.0"
6149source = "registry+https://github.com/rust-lang/crates.io-index"
6150checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449"
6151dependencies = [
6152 "base16ct 1.0.0",
6153 "ecdsa 0.17.0",
6154 "elliptic-curve 0.14.1",
6155 "primefield",
6156 "primeorder 0.14.0",
6157 "sha2 0.11.0",
6158]
6159
6160[[package]]
6161name = "pageant"
6162version = "0.2.2"
6163source = "registry+https://github.com/rust-lang/crates.io-index"
6164checksum = "3adadc44070da6f464b0918655a12f5792c156e088d8c4082d13e27d94c3e791"
6165dependencies = [
6166 "base16ct 1.0.0",
6167 "byteorder",
6168 "bytes",
6169 "delegate",
6170 "futures",
6171 "log 0.4.33",
6172 "rand 0.10.2",
6173 "sha2 0.11.0",
6174 "thiserror 2.0.20",
6175 "tokio",
6176 "windows",
6177 "windows-strings",
6178]
6179
6180[[package]]
61814194name = "parking"
61824195version = "2.2.1"
61834196source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6217,12 +4230,6 @@
62174230]
62184231
62194232[[package]]
6220name = "paste"
6221version = "1.0.15"
6222source = "registry+https://github.com/rust-lang/crates.io-index"
6223checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
6224
6225[[package]]
62264233name = "pbjson"
62274234version = "0.6.0"
62284235source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6260,16 +4267,6 @@
62604267]
62614268
62624269[[package]]
6263name = "pbkdf2"
6264version = "0.13.0"
6265source = "registry+https://github.com/rust-lang/crates.io-index"
6266checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
6267dependencies = [
6268 "digest 0.11.3",
6269 "hmac 0.13.0",
6270]
6271
6272[[package]]
62734270name = "pear"
62744271version = "0.2.9"
62754272source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6312,15 +4309,6 @@
63124309]
63134310
63144311[[package]]
6315name = "pem-rfc7468"
6316version = "1.0.0"
6317source = "registry+https://github.com/rust-lang/crates.io-index"
6318checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
6319dependencies = [
6320 "base64ct",
6321]
6322
6323[[package]]
63244312name = "peniko"
63254313version = "0.6.1"
63264314source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6346,48 +4334,6 @@
63464334checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
63474335
63484336[[package]]
6349name = "pest"
6350version = "2.9.0"
6351source = "registry+https://github.com/rust-lang/crates.io-index"
6352checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf"
6353dependencies = [
6354 "memchr",
6355 "ucd-trie",
6356]
6357
6358[[package]]
6359name = "pest_derive"
6360version = "2.9.0"
6361source = "registry+https://github.com/rust-lang/crates.io-index"
6362checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d"
6363dependencies = [
6364 "pest",
6365 "pest_generator",
6366]
6367
6368[[package]]
6369name = "pest_generator"
6370version = "2.9.0"
6371source = "registry+https://github.com/rust-lang/crates.io-index"
6372checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a"
6373dependencies = [
6374 "pest",
6375 "pest_meta",
6376 "proc-macro2",
6377 "quote",
6378 "syn 2.0.119",
6379]
6380
6381[[package]]
6382name = "pest_meta"
6383version = "2.9.0"
6384source = "registry+https://github.com/rust-lang/crates.io-index"
6385checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496"
6386dependencies = [
6387 "pest",
6388]
6389
6390[[package]]
63914337name = "petgraph"
63924338version = "0.6.5"
63934339source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6507,33 +4453,6 @@
65074453]
65084454
65094455[[package]]
6510name = "pkcs1"
6511version = "0.8.0-rc.4"
6512source = "registry+https://github.com/rust-lang/crates.io-index"
6513checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
6514dependencies = [
6515 "der 0.8.1",
6516 "spki 0.8.0",
6517]
6518
6519[[package]]
6520name = "pkcs5"
6521version = "0.8.1"
6522source = "registry+https://github.com/rust-lang/crates.io-index"
6523checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca"
6524dependencies = [
6525 "aes 0.9.2",
6526 "aes-gcm 0.11.0",
6527 "cbc",
6528 "der 0.8.1",
6529 "pbkdf2",
6530 "rand_core 0.10.1",
6531 "scrypt",
6532 "sha2 0.11.0",
6533 "spki 0.8.0",
6534]
6535
6536[[package]]
65374456name = "pkcs8"
65384457version = "0.10.2"
65394458source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6550,8 +4469,6 @@
65504469checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
65514470dependencies = [
65524471 "der 0.8.1",
6553 "pkcs5",
6554 "rand_core 0.10.1",
65554472 "spki 0.8.0",
65564473]
65574474
@@ -6598,31 +4515,6 @@
65984515]
65994516
66004517[[package]]
6601name = "polling"
6602version = "3.11.0"
6603source = "registry+https://github.com/rust-lang/crates.io-index"
6604checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
6605dependencies = [
6606 "cfg-if",
6607 "concurrent-queue",
6608 "hermit-abi",
6609 "pin-project-lite",
6610 "rustix",
6611 "windows-sys 0.61.2",
6612]
6613
6614[[package]]
6615name = "poly1305"
6616version = "0.9.1"
6617source = "registry+https://github.com/rust-lang/crates.io-index"
6618checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c"
6619dependencies = [
6620 "cpufeatures 0.3.0",
6621 "universal-hash 0.6.1",
6622 "zeroize",
6623]
6624
6625[[package]]
66264518name = "polycool"
66274519version = "0.4.0"
66284520source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6661,15 +4553,6 @@
66614553checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
66624554
66634555[[package]]
6664name = "portable-atomic-util"
6665version = "0.2.7"
6666source = "registry+https://github.com/rust-lang/crates.io-index"
6667checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
6668dependencies = [
6669 "portable-atomic",
6670]
6671
6672[[package]]
66734556name = "potential_utf"
66744557version = "0.1.6"
66754558source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6687,56 +4570,29 @@
66874570[[package]]
66884571name = "ppv-lite86"
66894572version = "0.2.21"
6690source = "registry+https://github.com/rust-lang/crates.io-index"
6691checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
6692dependencies = [
6693 "zerocopy",
6694]
6695
6696[[package]]
6697name = "prettyplease"
6698version = "0.2.37"
6699source = "registry+https://github.com/rust-lang/crates.io-index"
6700checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
6701dependencies = [
6702 "proc-macro2",
6703 "syn 2.0.119",
6704]
6705
6706[[package]]
6707name = "primefield"
6708version = "0.14.0"
67094573source = "registry+https://github.com/rust-lang/crates.io-index"
6710checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4"
4574checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
67114575dependencies = [
6712 "crypto-bigint 0.7.5",
6713 "crypto-common 0.2.2",
6714 "ff 0.14.0",
6715 "rand_core 0.10.1",
6716 "subtle",
6717 "zeroize",
4576 "zerocopy",
67184577]
67194578
67204579[[package]]
6721name = "primeorder"
6722version = "0.13.6"
4580name = "prettyplease"
4581version = "0.2.37"
67234582source = "registry+https://github.com/rust-lang/crates.io-index"
6724checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
4583checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
67254584dependencies = [
6726 "elliptic-curve 0.13.8",
4585 "proc-macro2",
4586 "syn 2.0.119",
67274587]
67284588
67294589[[package]]
67304590name = "primeorder"
6731version = "0.14.0"
4591version = "0.13.6"
67324592source = "registry+https://github.com/rust-lang/crates.io-index"
6733checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06"
4593checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
67344594dependencies = [
6735 "elliptic-curve 0.14.1",
6736 "once_cell",
6737 "primefield",
6738 "serdect",
6739 "wnaf",
4595 "elliptic-curve",
67404596]
67414597
67424598[[package]]
@@ -6771,17 +4627,6 @@
67714627]
67724628
67734629[[package]]
6774name = "prodash"
6775version = "31.0.0"
6776source = "registry+https://github.com/rust-lang/crates.io-index"
6777checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c"
6778dependencies = [
6779 "bytesize",
6780 "human_format",
6781 "parking_lot",
6782]
6783
6784[[package]]
67854630name = "prost"
67864631version = "0.12.6"
67874632source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7353,16 +5198,6 @@
73535198dependencies = [
73545199 "hmac 0.12.1",
73555200 "subtle",
7356]
7357
7358[[package]]
7359name = "rfc6979"
7360version = "0.6.0"
7361source = "registry+https://github.com/rust-lang/crates.io-index"
7362checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b"
7363dependencies = [
7364 "crypto-bigint 0.7.5",
7365 "hmac 0.13.0",
73665201]
73675202
73685203[[package]]
@@ -7375,7 +5210,7 @@
73755210 "cfg-if",
73765211 "getrandom 0.2.17",
73775212 "libc",
7378 "untrusted 0.9.0",
5213 "untrusted",
73795214 "windows-sys 0.52.0",
73805215]
73815216
@@ -7419,7 +5254,7 @@
74195254 "num-bigint-dig",
74205255 "num-integer",
74215256 "num-traits",
7422 "pkcs1 0.7.5",
5257 "pkcs1",
74235258 "pkcs8 0.10.2",
74245259 "rand_core 0.6.4",
74255260 "sha2 0.10.9",
@@ -7430,25 +5265,6 @@
74305265]
74315266
74325267[[package]]
7433name = "rsa"
7434version = "0.10.0-rc.18"
7435source = "registry+https://github.com/rust-lang/crates.io-index"
7436checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf"
7437dependencies = [
7438 "const-oid 0.10.2",
7439 "crypto-bigint 0.7.5",
7440 "crypto-primes",
7441 "digest 0.11.3",
7442 "pkcs1 0.8.0-rc.4",
7443 "pkcs8 0.11.0",
7444 "rand_core 0.10.1",
7445 "sha2 0.11.0",
7446 "signature 3.0.0",
7447 "spki 0.8.0",
7448 "zeroize",
7449]
7450
7451[[package]]
74525268name = "runner"
74535269version = "1.0.0"
74545270dependencies = [
@@ -7469,102 +5285,6 @@
74695285 "tracing",
74705286 "tracing-subscriber",
74715287 "uuid",
7472]
7473
7474[[package]]
7475name = "russh"
7476version = "0.63.1"
7477source = "registry+https://github.com/rust-lang/crates.io-index"
7478checksum = "35bab1b87d915817d5d9cc352637cd40d5f0b298a48c6309af9156a4addc3031"
7479dependencies = [
7480 "aes 0.9.2",
7481 "aws-lc-rs",
7482 "bitflags 2.13.1",
7483 "block-padding",
7484 "byteorder",
7485 "bytes",
7486 "cbc",
7487 "cipher 0.5.2",
7488 "crypto-bigint 0.7.5",
7489 "ctr 0.10.1",
7490 "curve25519-dalek",
7491 "data-encoding",
7492 "delegate",
7493 "der 0.8.1",
7494 "digest 0.11.3",
7495 "ecdsa 0.17.0",
7496 "ed25519-dalek",
7497 "elliptic-curve 0.14.1",
7498 "enum_dispatch",
7499 "flate2",
7500 "futures",
7501 "generic-array 1.4.5",
7502 "getrandom 0.4.3",
7503 "ghash 0.6.0",
7504 "hex-literal",
7505 "hmac 0.13.0",
7506 "inout 0.2.2",
7507 "internal-russh-num-bigint",
7508 "keccak",
7509 "log 0.4.33",
7510 "md5 0.8.1",
7511 "ml-kem",
7512 "module-lattice",
7513 "num-bigint 0.4.8",
7514 "p256 0.14.0",
7515 "p384 0.14.0",
7516 "p521",
7517 "pageant",
7518 "pbkdf2",
7519 "pkcs1 0.8.0-rc.4",
7520 "pkcs5",
7521 "pkcs8 0.11.0",
7522 "polyval 0.7.3",
7523 "rand 0.10.2",
7524 "rand_core 0.10.1",
7525 "rsa 0.10.0-rc.18",
7526 "russh-cryptovec",
7527 "russh-util",
7528 "salsa20",
7529 "scrypt",
7530 "sec1 0.8.1",
7531 "sha1 0.11.0",
7532 "sha2 0.11.0",
7533 "sha3 0.12.0",
7534 "signature 3.0.0",
7535 "spki 0.8.0",
7536 "ssh-encoding",
7537 "ssh-key",
7538 "subtle",
7539 "thiserror 2.0.20",
7540 "tokio",
7541 "typenum",
7542 "universal-hash 0.6.1",
7543 "zeroize",
7544]
7545
7546[[package]]
7547name = "russh-cryptovec"
7548version = "0.62.0"
7549source = "registry+https://github.com/rust-lang/crates.io-index"
7550checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438"
7551dependencies = [
7552 "log 0.4.33",
7553 "nix 0.31.3",
7554 "ssh-encoding",
7555 "windows-sys 0.61.2",
7556]
7557
7558[[package]]
7559name = "russh-util"
7560version = "0.52.0"
7561source = "registry+https://github.com/rust-lang/crates.io-index"
7562checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6"
7563dependencies = [
7564 "chrono",
7565 "tokio",
7566 "wasm-bindgen",
7567 "wasm-bindgen-futures",
75685288]
75695289
75705290[[package]]
@@ -7632,13 +5352,13 @@
76325352 "hyper 0.14.32",
76335353 "hyper-native-tls",
76345354 "hyper-tls",
7635 "md5 0.7.0",
5355 "md5",
76365356 "minidom",
76375357 "percent-encoding 2.3.2",
76385358 "quick-xml",
76395359 "serde",
76405360 "sha2 0.10.9",
7641 "strum_macros 0.24.3",
5361 "strum_macros",
76425362 "thiserror 1.0.69",
76435363 "time 0.3.55",
76445364 "tokio",
@@ -7766,7 +5486,7 @@
77665486 "aws-lc-rs",
77675487 "ring",
77685488 "rustls-pki-types",
7769 "untrusted 0.9.0",
5489 "untrusted",
77705490]
77715491
77725492[[package]]
@@ -7805,16 +5525,6 @@
78055525checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072"
78065526
78075527[[package]]
7808name = "salsa20"
7809version = "0.11.0"
7810source = "registry+https://github.com/rust-lang/crates.io-index"
7811checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c"
7812dependencies = [
7813 "cfg-if",
7814 "cipher 0.5.2",
7815]
7816
7817[[package]]
78185528name = "same-file"
78195529version = "1.0.6"
78205530source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7837,18 +5547,6 @@
78375547version = "1.2.0"
78385548source = "registry+https://github.com/rust-lang/crates.io-index"
78395549checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
7840
7841[[package]]
7842name = "scrypt"
7843version = "0.12.0"
7844source = "registry+https://github.com/rust-lang/crates.io-index"
7845checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0"
7846dependencies = [
7847 "cfg-if",
7848 "pbkdf2",
7849 "salsa20",
7850 "sha2 0.11.0",
7851]
78525550
78535551[[package]]
78545552name = "sea-bae"
@@ -7891,7 +5589,7 @@
78915589 "serde_json",
78925590 "sqlx",
78935591 "sqlx-core",
7894 "strum 0.28.0",
5592 "strum",
78955593 "thiserror 2.0.20",
78965594 "time 0.3.55",
78975595 "tracing",
@@ -7948,7 +5646,7 @@
79485646source = "registry+https://github.com/rust-lang/crates.io-index"
79495647checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537"
79505648dependencies = [
7951 "darling 0.20.11",
5649 "darling",
79525650 "heck 0.4.1",
79535651 "proc-macro2",
79545652 "quote",
@@ -8045,29 +5743,15 @@
80455743source = "registry+https://github.com/rust-lang/crates.io-index"
80465744checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
80475745dependencies = [
8048 "base16ct 0.2.0",
5746 "base16ct",
80495747 "der 0.7.10",
8050 "generic-array 0.14.9",
5748 "generic-array",
80515749 "pkcs8 0.10.2",
80525750 "subtle",
80535751 "zeroize",
80545752]
80555753
80565754[[package]]
8057name = "sec1"
8058version = "0.8.1"
8059source = "registry+https://github.com/rust-lang/crates.io-index"
8060checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
8061dependencies = [
8062 "base16ct 1.0.0",
8063 "ctutils",
8064 "der 0.8.1",
8065 "hybrid-array",
8066 "subtle",
8067 "zeroize",
8068]
8069
8070[[package]]
80715755name = "security-framework"
80725756version = "3.7.0"
80735757source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8186,16 +5870,6 @@
81865870]
81875871
81885872[[package]]
8189name = "serdect"
8190version = "0.4.3"
8191source = "registry+https://github.com/rust-lang/crates.io-index"
8192checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
8193dependencies = [
8194 "base16ct 1.0.0",
8195 "serde",
8196]
8197
8198[[package]]
81995873name = "sha1"
82005874version = "0.10.7"
82015875source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8218,16 +5892,6 @@
82185892]
82195893
82205894[[package]]
8221name = "sha1-checked"
8222version = "0.10.0"
8223source = "registry+https://github.com/rust-lang/crates.io-index"
8224checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423"
8225dependencies = [
8226 "digest 0.10.7",
8227 "sha1 0.10.7",
8228]
8229
8230[[package]]
82315895name = "sha1_smol"
82325896version = "1.0.1"
82335897source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8247,33 +5911,12 @@
82475911[[package]]
82485912name = "sha2"
82495913version = "0.11.0"
8250source = "registry+https://github.com/rust-lang/crates.io-index"
8251checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
8252dependencies = [
8253 "cfg-if",
8254 "cpufeatures 0.3.0",
8255 "digest 0.11.3",
8256]
8257
8258[[package]]
8259name = "sha3"
8260version = "0.11.0"
8261source = "registry+https://github.com/rust-lang/crates.io-index"
8262checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1"
8263dependencies = [
8264 "digest 0.11.3",
8265 "keccak",
8266]
8267
8268[[package]]
8269name = "sha3"
8270version = "0.12.0"
82715914source = "registry+https://github.com/rust-lang/crates.io-index"
8272checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759"
5915checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
82735916dependencies = [
5917 "cfg-if",
5918 "cpufeatures 0.3.0",
82745919 "digest 0.11.3",
8275 "keccak",
8276 "sponge-cursor",
82775920]
82785921
82795922[[package]]
@@ -8297,28 +5940,12 @@
82975940]
82985941
82995942[[package]]
8300name = "shell-words"
8301version = "1.1.1"
8302source = "registry+https://github.com/rust-lang/crates.io-index"
8303checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
8304
8305[[package]]
83065943name = "shlex"
83075944version = "2.0.1"
83085945source = "registry+https://github.com/rust-lang/crates.io-index"
83095946checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
83105947
83115948[[package]]
8312name = "signal-hook"
8313version = "0.4.4"
8314source = "registry+https://github.com/rust-lang/crates.io-index"
8315checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d"
8316dependencies = [
8317 "libc",
8318 "signal-hook-registry",
8319]
8320
8321[[package]]
83225949name = "signal-hook-registry"
83235950version = "1.4.8"
83245951source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8393,12 +6020,6 @@
83936020version = "0.4.12"
83946021source = "registry+https://github.com/rust-lang/crates.io-index"
83956022checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
8396
8397[[package]]
8398name = "small_ctor"
8399version = "0.1.2"
8400source = "registry+https://github.com/rust-lang/crates.io-index"
8401checksum = "88414a5ca1f85d82cc34471e975f0f74f6aa54c40f062efa42c0080e7f763f81"
84026023
84036024[[package]]
84046025name = "smallvec"
@@ -8620,7 +6241,7 @@
86206241 "either",
86216242 "futures-core",
86226243 "futures-util",
8623 "generic-array 0.14.9",
6244 "generic-array",
86246245 "log 0.4.33",
86256246 "percent-encoding 2.3.2",
86266247 "rust_decimal",
@@ -8701,65 +6322,6 @@
87016322]
87026323
87036324[[package]]
8704name = "ssh-cipher"
8705version = "0.3.0"
8706source = "registry+https://github.com/rust-lang/crates.io-index"
8707checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597"
8708dependencies = [
8709 "aead 0.6.1",
8710 "aes 0.9.2",
8711 "aes-gcm 0.11.0",
8712 "chacha20",
8713 "cipher 0.5.2",
8714 "ctutils",
8715 "des",
8716 "poly1305",
8717 "ssh-encoding",
8718 "zeroize",
8719]
8720
8721[[package]]
8722name = "ssh-encoding"
8723version = "0.3.0"
8724source = "registry+https://github.com/rust-lang/crates.io-index"
8725checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e"
8726dependencies = [
8727 "base64ct",
8728 "bytes",
8729 "crypto-bigint 0.7.5",
8730 "ctutils",
8731 "digest 0.11.3",
8732 "pem-rfc7468 1.0.0",
8733 "zeroize",
8734]
8735
8736[[package]]
8737name = "ssh-key"
8738version = "0.7.0-rc.11"
8739source = "registry+https://github.com/rust-lang/crates.io-index"
8740checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3"
8741dependencies = [
8742 "argon2",
8743 "bcrypt-pbkdf",
8744 "ctutils",
8745 "ed25519-dalek",
8746 "hex",
8747 "hmac 0.13.0",
8748 "p256 0.14.0",
8749 "p384 0.14.0",
8750 "p521",
8751 "rand_core 0.10.1",
8752 "rsa 0.10.0-rc.18",
8753 "sec1 0.8.1",
8754 "sha1 0.11.0",
8755 "sha2 0.11.0",
8756 "signature 3.0.0",
8757 "ssh-cipher",
8758 "ssh-encoding",
8759 "zeroize",
8760]
8761
8762[[package]]
87636325name = "stable_deref_trait"
87646326version = "1.2.1"
87656327source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8772,12 +6334,6 @@
87726334checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
87736335
87746336[[package]]
8775name = "static_assertions_next"
8776version = "1.1.2"
8777source = "registry+https://github.com/rust-lang/crates.io-index"
8778checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766"
8779
8780[[package]]
87816337name = "storage"
87826338version = "1.0.0"
87836339dependencies = [
@@ -8808,21 +6364,6 @@
88086364]
88096365
88106366[[package]]
8811name = "strsim"
8812version = "0.11.1"
8813source = "registry+https://github.com/rust-lang/crates.io-index"
8814checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
8815
8816[[package]]
8817name = "strum"
8818version = "0.27.2"
8819source = "registry+https://github.com/rust-lang/crates.io-index"
8820checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
8821dependencies = [
8822 "strum_macros 0.27.2",
8823]
8824
8825[[package]]
88266367name = "strum"
88276368version = "0.28.0"
88286369source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8839,18 +6380,6 @@
88396380 "quote",
88406381 "rustversion",
88416382 "syn 1.0.109",
8842]
8843
8844[[package]]
8845name = "strum_macros"
8846version = "0.27.2"
8847source = "registry+https://github.com/rust-lang/crates.io-index"
8848checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
8849dependencies = [
8850 "heck 0.5.0",
8851 "proc-macro2",
8852 "quote",
8853 "syn 2.0.119",
88546383]
88556384
88566385[[package]]
@@ -8873,7 +6402,7 @@
88736402 "hmac-sha512",
88746403 "ml-dsa",
88756404 "rand 0.8.7",
8876 "rsa 0.9.10",
6405 "rsa",
88776406]
88786407
88796408[[package]]
@@ -8929,6 +6458,7 @@
89296458 "export",
89306459 "git",
89316460 "import",
6461 "kenshell",
89326462 "search",
89336463 "tokio",
89346464 "tokio-util",
@@ -9613,15 +7143,6 @@
96137143]
96147144
96157145[[package]]
9616name = "twox-hash"
9617version = "2.1.3"
9618source = "registry+https://github.com/rust-lang/crates.io-index"
9619checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9"
9620dependencies = [
9621 "rand 0.10.2",
9622]
9623
9624[[package]]
96257146name = "typeable"
96267147version = "0.1.2"
96277148source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -9634,21 +7155,6 @@
96347155checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
96357156
96367157[[package]]
9637name = "ucd-trie"
9638version = "0.1.7"
9639source = "registry+https://github.com/rust-lang/crates.io-index"
9640checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
9641
9642[[package]]
9643name = "uluru"
9644version = "3.1.0"
9645source = "registry+https://github.com/rust-lang/crates.io-index"
9646checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da"
9647dependencies = [
9648 "arrayvec",
9649]
9650
9651[[package]]
96527158name = "uncased"
96537159version = "0.9.10"
96547160source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -9679,12 +7185,6 @@
96797185checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
96807186
96817187[[package]]
9682name = "unicode-bom"
9683version = "2.0.3"
9684source = "registry+https://github.com/rust-lang/crates.io-index"
9685checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217"
9686
9687[[package]]
96887188name = "unicode-ident"
96897189version = "1.0.24"
96907190source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -9736,12 +7236,6 @@
97367236version = "0.2.11"
97377237source = "registry+https://github.com/rust-lang/crates.io-index"
97387238checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
9739
9740[[package]]
9741name = "untrusted"
9742version = "0.7.1"
9743source = "registry+https://github.com/rust-lang/crates.io-index"
9744checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
97457239
97467240[[package]]
97477241name = "untrusted"
@@ -10053,7 +7547,7 @@
100537547 "hkdf 0.13.0",
100547548 "http 1.5.0",
100557549 "jwt-simple",
10056 "p256 0.13.2",
7550 "p256",
100577551 "serde",
100587552 "sha2 0.11.0",
100597553]
@@ -10205,27 +7699,6 @@
102057699checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
102067700
102077701[[package]]
10208name = "windows"
10209version = "0.62.2"
10210source = "registry+https://github.com/rust-lang/crates.io-index"
10211checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
10212dependencies = [
10213 "windows-collections",
10214 "windows-core",
10215 "windows-future",
10216 "windows-numerics",
10217]
10218
10219[[package]]
10220name = "windows-collections"
10221version = "0.3.2"
10222source = "registry+https://github.com/rust-lang/crates.io-index"
10223checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
10224dependencies = [
10225 "windows-core",
10226]
10227
10228[[package]]
102297702name = "windows-core"
102307703version = "0.62.2"
102317704source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -10239,17 +7712,6 @@
102397712]
102407713
102417714[[package]]
10242name = "windows-future"
10243version = "0.3.2"
10244source = "registry+https://github.com/rust-lang/crates.io-index"
10245checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
10246dependencies = [
10247 "windows-core",
10248 "windows-link",
10249 "windows-threading",
10250]
10251
10252[[package]]
102537715name = "windows-implement"
102547716version = "0.60.2"
102557717source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -10278,16 +7740,6 @@
102787740checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
102797741
102807742[[package]]
10281name = "windows-numerics"
10282version = "0.3.1"
10283source = "registry+https://github.com/rust-lang/crates.io-index"
10284checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
10285dependencies = [
10286 "windows-core",
10287 "windows-link",
10288]
10289
10290[[package]]
102917743name = "windows-result"
102927744version = "0.4.1"
102937745source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -10397,15 +7849,6 @@
103977849]
103987850
103997851[[package]]
10400name = "windows-threading"
10401version = "0.2.1"
10402source = "registry+https://github.com/rust-lang/crates.io-index"
10403checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
10404dependencies = [
10405 "windows-link",
10406]
10407
10408[[package]]
104097852name = "windows_aarch64_gnullvm"
104107853version = "0.42.2"
104117854source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -10562,17 +8005,6 @@
105628005checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
105638006
105648007[[package]]
10565name = "wnaf"
10566version = "0.14.0"
10567source = "registry+https://github.com/rust-lang/crates.io-index"
10568checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1"
10569dependencies = [
10570 "ff 0.14.0",
10571 "group 0.14.0",
10572 "hybrid-array",
10573]
10574
10575[[package]]
105768008name = "writeable"
105778009version = "0.6.4"
105788010source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -10752,34 +8184,6 @@
107528184 "crc32fast",
107538185 "log 0.4.33",
107548186 "simd-adler32",
10755]
10756
10757[[package]]
10758name = "zstd"
10759version = "0.13.3"
10760source = "registry+https://github.com/rust-lang/crates.io-index"
10761checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
10762dependencies = [
10763 "zstd-safe",
10764]
10765
10766[[package]]
10767name = "zstd-safe"
10768version = "7.2.4"
10769source = "registry+https://github.com/rust-lang/crates.io-index"
10770checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
10771dependencies = [
10772 "zstd-sys",
10773]
10774
10775[[package]]
10776name = "zstd-sys"
10777version = "2.0.16+zstd.1.5.7"
10778source = "registry+https://github.com/rust-lang/crates.io-index"
10779checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
10780dependencies = [
10781 "cc",
10782 "pkg-config",
107838187]
107848188
107858189[[package]]
Modified Cargo.toml View Cargo.toml
@@ -31,6 +31,7 @@
3131i18n = { path = "lib/i18n" }
3232imaging = { path = "lib/imaging" }
3333kenauth = { path = "lib/kenauth" }
34kenshell = { path = "lib/kenshell" }
3435jwt = { path = "lib/jwt" }
3536import = { path = "lib/import" }
3637webapi = { path = "lib/webapi" }
@@ -63,18 +64,15 @@
6364redis = { version = "1.6.0", features = ["aio", "tokio-comp", "connection-manager", "cluster-async"] }
6465sea-orm = { version = "2.0.2", features = ["sqlx-postgres", "runtime-tokio-rustls"] }
6566async-trait = "0.1.92"
66async-graphql = "7.0"
6767base64 = "0.23.1"
6868rand = "0.8"
6969sha2 = "0.11.0"
70sha1 = "0.11.0"
7170hmac = "0.13"
7271hkdf = "0.13"
7372uuid = { version = "1", features = ["v7", "serde"] }
7473argon2 = "0.6.0"
7574aes-gcm = "0.10"
7675time = { version = "0.3", features = ["serde", "serde-human-readable"] }
77ssh-key = "0.6"
7876bytes = "1"
7977futures = "0.3"
8078http = "0.2"
@@ -82,13 +80,9 @@
8280tokio-util = { version = "0.7", features = ["io"] }
8381flate2 = "1"
8482globset = "0.4"
85russh = "0.63.1"
8683utoipa = { version = "5.5", features = ["axum_extras", "time", "uuid"] }
8784utoipa-swagger-ui = { version = "9", features = ["axum"] }
8885thiserror = "2.0.20"
89gix = "0.86.0"
90gitbaby = { path = "lib/gitbaby" }
91foyer = { version = "0.22.3", features = ["runtime-tokio"] }
9286hayro = { version = "0.7.1", features = ["embed-fonts", "embed-cmaps"] }
9387maud = { version = "0.27", features = ["axum"] }
9488pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }