Skip to main content

max / makenotwork

Decompose AppState Phase 2: migrate git to slices Add a Git slice (syntax highlighter + smart-HTTP clone semaphore) drawn from the top level and AppLimiters, and migrate the git source-browser module. Shared helpers decoupled from AppState: - repos_root(&Config), resolve_repo(&PgPool, &Config, ...), resolve_git_http_principal(&PgPool, ...), fetch_linked_releases(&PgPool, ...), authorize_push(&PgPool, ...) Handlers now extract only their slices: - browsing/raw: State<PgPool> + State<Config>; tree_or_file and the upload/receive-pack handlers add State<Git> (syntax / clone permit) - user_repos, git_landing: State<PgPool> git_issues shares resolve_repo/repos_root; its call sites are adapted to pass the narrowed args from their existing state (that module keeps State<AppState> until its own migration). No behavior change; 77 git integration tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 20:15 UTC
Commit: 2374605bfdd33a572a327d0b349bfcc5e7b5c3cb
Parent: 5e08c09
8 files changed, +122 insertions, -81 deletions
@@ -276,6 +276,25 @@ impl FromRef<AppState> for Sync {
276 276 }
277 277 }
278 278
279 + /// Git source-browser slice: the syntax highlighter (blob rendering) plus the
280 + /// semaphore that bounds concurrent smart-HTTP clone/fetch child processes.
281 + /// Draws `syntax` from the top level and `git_smart_http_semaphore` from
282 + /// [`AppLimiters`].
283 + #[derive(Clone)]
284 + pub struct Git {
285 + pub syntax: Option<Arc<git::SyntaxHighlighter>>,
286 + pub smart_http_semaphore: Arc<tokio::sync::Semaphore>,
287 + }
288 +
289 + impl FromRef<AppState> for Git {
290 + fn from_ref(s: &AppState) -> Self {
291 + Self {
292 + syntax: s.syntax.clone(),
293 + smart_http_semaphore: s.limiters.git_smart_http_semaphore.clone(),
294 + }
295 + }
296 + }
297 +
279 298 /// Observability/lifecycle slice: uptime clocks, the pending-restart flag, and
280 299 /// the Prometheus render handle. Used by status/health/admin/metrics handlers.
281 300 /// (`started_at`/`start_instant` are `#[from_ref(skip)]` on `AppState`, so this
@@ -716,6 +735,7 @@ mod from_ref_slices {
716 735 _assert_from_ref::<Integrations>();
717 736 _assert_from_ref::<Scanning>();
718 737 _assert_from_ref::<Sync>();
738 + _assert_from_ref::<Git>();
719 739 _assert_from_ref::<Ops>();
720 740 }
721 741 }
@@ -8,15 +8,18 @@ use axum::{
8 8 use serde::Deserialize;
9 9 use tower_sessions::Session;
10 10
11 + use sqlx::PgPool;
12 +
11 13 use crate::{
12 14 auth::MaybeUserVerified,
15 + config::Config,
13 16 constants,
14 17 db,
15 18 error::{AppError, Result},
16 19 git,
17 20 helpers::get_csrf_token,
18 21 templates::*,
19 - AppState,
22 + Git,
20 23 };
21 24
22 25 use super::{
@@ -26,12 +29,13 @@ use super::{
26 29 /// `GET /git/{owner}/{repo}`: repo overview (tree at HEAD + README).
27 30 #[tracing::instrument(skip_all, name = "git::repo_overview")]
28 31 pub(super) async fn repo_overview(
29 - State(state): State<AppState>,
32 + State(db): State<PgPool>,
33 + State(config): State<Config>,
30 34 session: Session,
31 35 MaybeUserVerified(maybe_user): MaybeUserVerified,
32 36 Path((owner, repo_name)): Path<(String, String)>,
33 37 ) -> Result<impl IntoResponse> {
34 - let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
38 + let resolved = resolve_repo(&db, &config,&owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
35 39 let repo_name_c = repo_name.clone();
36 40 let (info, refs, tree_items, readme_html) = resolved.with_repo(move |repo| {
37 41 let info = git::repo_info(repo, &repo_name_c);
@@ -45,8 +49,8 @@ pub(super) async fn repo_overview(
45 49 let csrf_token = get_csrf_token(&session).await;
46 50
47 51 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
48 - let (linked_project, release_items) = fetch_linked_releases(&state, &resolved.db_repo).await;
49 - let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await.unwrap_or((0, 0));
52 + let (linked_project, release_items) = fetch_linked_releases(&db,&resolved.db_repo).await;
53 + let (open_issue_count, _) = db::issues::get_issue_counts(&db, resolved.db_repo.id).await.unwrap_or((0, 0));
50 54
51 55 let description = if resolved.db_repo.description.is_empty() {
52 56 info.description
@@ -64,8 +68,8 @@ pub(super) async fn repo_overview(
64 68 refs,
65 69 tree_items,
66 70 readme_html,
67 - host_url: state.config.host_url.clone(),
68 - git_ssh_host: state.config.build.git_ssh_host.clone(),
71 + host_url: config.host_url.clone(),
72 + git_ssh_host: config.build.git_ssh_host.clone(),
69 73 linked_project,
70 74 release_items,
71 75 open_issue_count,
@@ -77,12 +81,13 @@ pub(super) async fn repo_overview(
77 81 /// `GET /git/{owner}/{repo}/tree/{ref}`: tree root at a specific ref.
78 82 #[tracing::instrument(skip_all, name = "git::tree_root")]
79 83 pub(super) async fn tree_root(
80 - State(state): State<AppState>,
84 + State(db): State<PgPool>,
85 + State(config): State<Config>,
81 86 session: Session,
82 87 MaybeUserVerified(maybe_user): MaybeUserVerified,
83 88 Path((owner, repo_name, git_ref)): Path<(String, String, String)>,
84 89 ) -> Result<impl IntoResponse> {
85 - let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
90 + let resolved = resolve_repo(&db, &config,&owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
86 91 let git_ref_c = git_ref.clone();
87 92 let (refs, tree_items, readme_html) = resolved.with_repo(move |repo| {
88 93 let refs = git::list_refs(repo);
@@ -95,8 +100,8 @@ pub(super) async fn tree_root(
95 100 let csrf_token = get_csrf_token(&session).await;
96 101 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
97 102
98 - let (linked_project, release_items) = fetch_linked_releases(&state, &resolved.db_repo).await;
99 - let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await.unwrap_or((0, 0));
103 + let (linked_project, release_items) = fetch_linked_releases(&db,&resolved.db_repo).await;
104 + let (open_issue_count, _) = db::issues::get_issue_counts(&db, resolved.db_repo.id).await.unwrap_or((0, 0));
100 105
101 106 let description = if resolved.db_repo.description.is_empty() {
102 107 None
@@ -114,8 +119,8 @@ pub(super) async fn tree_root(
114 119 refs,
115 120 tree_items,
116 121 readme_html,
117 - host_url: state.config.host_url.clone(),
118 - git_ssh_host: state.config.build.git_ssh_host.clone(),
122 + host_url: config.host_url.clone(),
123 + git_ssh_host: config.build.git_ssh_host.clone(),
119 124 linked_project,
120 125 release_items,
121 126 open_issue_count,
@@ -127,14 +132,16 @@ pub(super) async fn tree_root(
127 132 /// `GET /git/{owner}/{repo}/tree/{ref}/{*path}`: file or subdirectory.
128 133 #[tracing::instrument(skip_all, name = "git::tree_or_file")]
129 134 pub(super) async fn tree_or_file(
130 - State(state): State<AppState>,
135 + State(db): State<PgPool>,
136 + State(config): State<Config>,
137 + State(git): State<Git>,
131 138 session: Session,
132 139 MaybeUserVerified(maybe_user): MaybeUserVerified,
133 140 Path((owner, repo_name, git_ref, path)): Path<(String, String, String, String)>,
134 141 ) -> Result<Response> {
135 - let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
142 + let resolved = resolve_repo(&db, &config,&owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
136 143 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
137 - let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await.unwrap_or((0, 0));
144 + let (open_issue_count, _) = db::issues::get_issue_counts(&db, resolved.db_repo.id).await.unwrap_or((0, 0));
138 145
139 146 // Resolve the ref and read the path as a directory (preferred) or file in
140 147 // one blocking-pool pass.
@@ -195,7 +202,7 @@ pub(super) async fn tree_or_file(
195 202
196 203 let highlighted_lines = if file_content.is_binary {
197 204 Vec::new()
198 - } else if let Some(ref highlighter) = state.syntax {
205 + } else if let Some(ref highlighter) = git.syntax {
199 206 let highlighted = highlighter.highlight(&file_content.content, &filename);
200 207 highlighted.lines().map(String::from).collect()
201 208 } else {
@@ -243,13 +250,14 @@ pub(super) struct CommitQuery {
243 250 /// `GET /git/{owner}/{repo}/commits/{ref}`: commit log with pagination.
244 251 #[tracing::instrument(skip_all, name = "git::commit_log")]
245 252 pub(super) async fn commit_log(
246 - State(state): State<AppState>,
253 + State(db): State<PgPool>,
254 + State(config): State<Config>,
247 255 session: Session,
248 256 MaybeUserVerified(maybe_user): MaybeUserVerified,
249 257 Path((owner, repo_name, git_ref)): Path<(String, String, String)>,
250 258 Query(query): Query<CommitQuery>,
251 259 ) -> Result<impl IntoResponse> {
252 - let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
260 + let resolved = resolve_repo(&db, &config,&owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
253 261
254 262 let page = query.page.unwrap_or(1).clamp(1, 10_000);
255 263 let limit = constants::GIT_COMMITS_PER_PAGE;
@@ -268,7 +276,7 @@ pub(super) async fn commit_log(
268 276 let csrf_token = get_csrf_token(&session).await;
269 277 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
270 278
271 - let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await.unwrap_or((0, 0));
279 + let (open_issue_count, _) = db::issues::get_issue_counts(&db, resolved.db_repo.id).await.unwrap_or((0, 0));
272 280
273 281 Ok(GitCommitsTemplate {
274 282 csrf_token,
@@ -289,12 +297,13 @@ pub(super) async fn commit_log(
289 297 /// `GET /git/{owner}/{repo}/commit/{oid}`: commit detail with inline diffs.
290 298 #[tracing::instrument(skip_all, name = "git::commit_detail_page")]
291 299 pub(super) async fn commit_detail_page(
292 - State(state): State<AppState>,
300 + State(db): State<PgPool>,
301 + State(config): State<Config>,
293 302 session: Session,
294 303 MaybeUserVerified(maybe_user): MaybeUserVerified,
295 304 Path((owner, repo_name, oid_str)): Path<(String, String, String)>,
296 305 ) -> Result<impl IntoResponse> {
297 - let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
306 + let resolved = resolve_repo(&db, &config,&owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
298 307
299 308 let oid = git2::Oid::from_str(&oid_str).map_err(|_| AppError::NotFound)?;
300 309 let repo_name_c = repo_name.clone();
@@ -318,7 +327,7 @@ pub(super) async fn commit_detail_page(
318 327
319 328 let csrf_token = get_csrf_token(&session).await;
320 329 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
321 - let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await.unwrap_or((0, 0));
330 + let (open_issue_count, _) = db::issues::get_issue_counts(&db, resolved.db_repo.id).await.unwrap_or((0, 0));
322 331
323 332 Ok(GitCommitDetailTemplate {
324 333 csrf_token,
@@ -341,12 +350,13 @@ pub(super) async fn commit_detail_page(
341 350 /// `GET /git/{owner}/{repo}/blame/{ref}/{*path}`: blame view.
342 351 #[tracing::instrument(skip_all, name = "git::blame_view")]
343 352 pub(super) async fn blame_view(
344 - State(state): State<AppState>,
353 + State(db): State<PgPool>,
354 + State(config): State<Config>,
345 355 session: Session,
346 356 MaybeUserVerified(maybe_user): MaybeUserVerified,
347 357 Path((owner, repo_name, git_ref, path)): Path<(String, String, String, String)>,
348 358 ) -> Result<impl IntoResponse> {
349 - let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
359 + let resolved = resolve_repo(&db, &config,&owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
350 360 let git_ref_c = git_ref.clone();
351 361 let path_c = path.clone();
352 362 let (blame_lines, refs) = resolved.with_repo(move |repo| {
@@ -361,7 +371,7 @@ pub(super) async fn blame_view(
361 371
362 372 let csrf_token = get_csrf_token(&session).await;
363 373 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
364 - let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await.unwrap_or((0, 0));
374 + let (open_issue_count, _) = db::issues::get_issue_counts(&db, resolved.db_repo.id).await.unwrap_or((0, 0));
365 375
366 376 Ok(GitBlameTemplate {
367 377 csrf_token,
@@ -383,22 +393,22 @@ pub(super) async fn blame_view(
383 393 /// `GET /git/{owner}`: user's repository listing.
384 394 #[tracing::instrument(skip_all, name = "git::user_repos")]
385 395 pub(super) async fn user_repos(
386 - State(state): State<AppState>,
396 + State(db): State<PgPool>,
387 397 session: Session,
388 398 MaybeUserVerified(maybe_user): MaybeUserVerified,
389 399 Path(owner): Path<String>,
390 400 ) -> Result<impl IntoResponse> {
391 401 let username = db::Username::new(&owner).map_err(|_| AppError::NotFound)?;
392 - let db_user = db::users::get_user_by_username(&state.db, &username)
402 + let db_user = db::users::get_user_by_username(&db, &username)
393 403 .await?
394 404 .ok_or(AppError::NotFound)?;
395 405
396 406 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(db_user.id);
397 407
398 408 let repos = if is_owner {
399 - db::git_repos::get_repos_by_user(&state.db, db_user.id).await?
409 + db::git_repos::get_repos_by_user(&db, db_user.id).await?
400 410 } else {
401 - db::git_repos::get_public_repos_by_user(&state.db, db_user.id).await?
411 + db::git_repos::get_public_repos_by_user(&db, db_user.id).await?
402 412 };
403 413
404 414 let csrf_token = get_csrf_token(&session).await;
@@ -421,7 +431,7 @@ pub(super) struct ExploreQuery {
421 431 /// `GET /git`: public explore page listing all public repos.
422 432 #[tracing::instrument(skip_all, name = "git::git_explore")]
423 433 pub(super) async fn git_landing(
424 - State(state): State<AppState>,
434 + State(db): State<PgPool>,
425 435 session: Session,
426 436 MaybeUserVerified(maybe_user): MaybeUserVerified,
427 437 Query(query): Query<ExploreQuery>,
@@ -430,10 +440,10 @@ pub(super) async fn git_landing(
430 440 let limit = constants::GIT_REPOS_PER_PAGE;
431 441 let offset = (page - 1).saturating_mul(limit);
432 442
433 - let repos = db::git_repos::get_all_public_repos(&state.db, (limit + 1) as i64, offset as i64).await?;
443 + let repos = db::git_repos::get_all_public_repos(&db, (limit + 1) as i64, offset as i64).await?;
434 444 let has_more = repos.len() > limit;
435 445 let repos: Vec<_> = repos.into_iter().take(limit).collect();
436 - let total_count = db::git_repos::count_all_public_repos(&state.db).await?;
446 + let total_count = db::git_repos::count_all_public_repos(&db).await?;
437 447
438 448 let csrf_token = get_csrf_token(&session).await;
439 449
@@ -450,13 +460,14 @@ pub(super) async fn git_landing(
450 460 /// `GET /git/{owner}/{repo}/log/{ref}/{*path}`: per-file commit history.
451 461 #[tracing::instrument(skip_all, name = "git::file_log")]
452 462 pub(super) async fn file_log(
453 - State(state): State<AppState>,
463 + State(db): State<PgPool>,
464 + State(config): State<Config>,
454 465 session: Session,
455 466 MaybeUserVerified(maybe_user): MaybeUserVerified,
456 467 Path((owner, repo_name, git_ref, path)): Path<(String, String, String, String)>,
457 468 Query(query): Query<CommitQuery>,
458 469 ) -> Result<impl IntoResponse> {
459 - let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
470 + let resolved = resolve_repo(&db, &config,&owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
460 471
461 472 let page = query.page.unwrap_or(1).clamp(1, 10_000);
462 473 let limit = constants::GIT_COMMITS_PER_PAGE;
@@ -484,7 +495,7 @@ pub(super) async fn file_log(
484 495 let breadcrumbs = build_breadcrumbs(&path);
485 496 let csrf_token = get_csrf_token(&session).await;
486 497 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
487 - let (open_issue_count, _) = db::issues::get_issue_counts(&state.db, resolved.db_repo.id).await.unwrap_or((0, 0));
498 + let (open_issue_count, _) = db::issues::get_issue_counts(&db, resolved.db_repo.id).await.unwrap_or((0, 0));
488 499
489 500 Ok(GitFileLogTemplate {
490 501 csrf_token,
@@ -12,7 +12,10 @@ use axum::{
12 12 };
13 13 use tower_governor::GovernorLayer;
14 14
15 + use sqlx::PgPool;
16 +
15 17 use crate::{
18 + config::Config,
16 19 constants,
17 20 db::{self, DbGitRepo, DbUser, UserId, Username},
18 21 error::{AppError, Result},
@@ -78,9 +81,8 @@ pub fn format_size(bytes: &u64) -> String {
78 81 use crate::helpers::escape_html;
79 82
80 83 /// Get the repos root path from config, or return 404 if git is not configured.
81 - pub(crate) fn repos_root(state: &AppState) -> Result<std::path::PathBuf> {
82 - state
83 - .config
84 + pub(crate) fn repos_root(config: &Config) -> Result<std::path::PathBuf> {
85 + config
84 86 .build.git_repos_path
85 87 .as_ref()
86 88 .map(std::path::PathBuf::from)
@@ -161,21 +163,22 @@ fn resolve_repo_name(repo_name: &str) -> &str {
161 163
162 164 #[tracing::instrument(skip_all, name = "git::resolve_repo", fields(owner = %owner, repo = %repo_name))]
163 165 pub(crate) async fn resolve_repo(
164 - state: &AppState,
166 + db: &PgPool,
167 + config: &Config,
165 168 owner: &str,
166 169 repo_name: &str,
167 170 session_user_id: Option<UserId>,
168 171 ) -> Result<ResolvedRepo> {
169 - let root = repos_root(state)?;
172 + let root = repos_root(config)?;
170 173
171 174 // 1. Look up user by URL owner
172 175 let username = Username::new(owner).map_err(|_| AppError::NotFound)?;
173 - let db_user = db::users::get_user_by_username(&state.db, &username)
176 + let db_user = db::users::get_user_by_username(db, &username)
174 177 .await?
175 178 .ok_or(AppError::NotFound)?;
176 179
177 180 // 2. Look up repo in DB
178 - let db_repo = match db::git_repos::get_repo_by_user_and_name(&state.db, db_user.id, repo_name).await? {
181 + let db_repo = match db::git_repos::get_repo_by_user_and_name(db, db_user.id, repo_name).await? {
179 182 Some(r) => r,
180 183 None => {
181 184 // Auto-register: repo exists on disk but not in DB. Only the owner
@@ -191,11 +194,11 @@ pub(crate) async fn resolve_repo(
191 194 if git::open_repo(&root, disk_dir, repo_name).is_err() {
192 195 return Err(AppError::NotFound);
193 196 }
194 - match db::git_repos::create_repo(&state.db, db_user.id, repo_name).await {
197 + match db::git_repos::create_repo(db, db_user.id, repo_name).await {
195 198 Ok(r) => r,
196 199 Err(e) => {
197 200 tracing::debug!(owner, repo_name, error = ?e, "auto-register failed, retrying lookup");
198 - db::git_repos::get_repo_by_user_and_name(&state.db, db_user.id, repo_name)
201 + db::git_repos::get_repo_by_user_and_name(db, db_user.id, repo_name)
199 202 .await?
200 203 .ok_or(AppError::NotFound)?
201 204 }
@@ -211,7 +214,7 @@ pub(crate) async fn resolve_repo(
211 214 let is_owner = session_user_id == Some(db_user.id);
212 215 if db_repo.visibility == db::Visibility::Private && !is_owner {
213 216 let is_collaborator = match session_user_id {
214 - Some(uid) => db::repo_collaborators::is_collaborator(&state.db, db_repo.id, uid).await?,
217 + Some(uid) => db::repo_collaborators::is_collaborator(db, db_repo.id, uid).await?,
215 218 None => false,
216 219 };
217 220 if !is_collaborator {
@@ -245,7 +248,7 @@ pub(crate) struct GitHttpPrincipal {
245 248 /// or expired credentials — callers treat that as unauthenticated, and a
246 249 /// private repo then 404s exactly as it does for an anonymous browser request.
247 250 pub(crate) async fn resolve_git_http_principal(
248 - state: &AppState,
251 + db: &PgPool,
249 252 headers: &axum::http::HeaderMap,
250 253 session_user_id: Option<UserId>,
251 254 ) -> Option<GitHttpPrincipal> {
@@ -255,7 +258,7 @@ pub(crate) async fn resolve_git_http_principal(
255 258 let header = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
256 259 let token = parse_basic_auth_token(header)?;
257 260 let hash = crate::crypto::git_token_hash(&token);
258 - let resolved = db::git_access_tokens::resolve_active(&state.db, &hash).await.ok()??;
261 + let resolved = db::git_access_tokens::resolve_active(db, &hash).await.ok()??;
259 262 Some(GitHttpPrincipal { user_id: resolved.user_id, token_push: Some(resolved.can_push) })
260 263 }
261 264
@@ -280,7 +283,7 @@ fn parse_basic_auth_token(header: &str) -> Option<String> {
280 283 /// Look up the project linked to a git repo and fetch its public items with versions.
281 284 #[tracing::instrument(skip_all, name = "git::fetch_linked_releases", fields(project_id = tracing::field::Empty))]
282 285 async fn fetch_linked_releases(
283 - state: &AppState,
286 + db: &PgPool,
284 287 db_repo: &DbGitRepo,
285 288 ) -> (Option<Project>, Vec<ReleaseItem>) {
286 289 let project_id = match db_repo.project_id {
@@ -289,7 +292,7 @@ async fn fetch_linked_releases(
289 292 };
290 293 tracing::Span::current().record("project_id", tracing::field::display(project_id));
291 294
292 - let db_project = match db::projects::get_project_by_id(&state.db, project_id).await {
295 + let db_project = match db::projects::get_project_by_id(db, project_id).await {
293 296 Ok(Some(p)) if p.is_public => p,
294 297 Ok(_) => return (None, Vec::new()),
295 298 Err(e) => {
@@ -298,7 +301,7 @@ async fn fetch_linked_releases(
298 301 }
299 302 };
300 303
301 - let db_items = match db::items::get_public_items_by_project(&state.db, db_project.id).await {
304 + let db_items = match db::items::get_public_items_by_project(db, db_project.id).await {
302 305 Ok(items) => items,
303 306 Err(e) => {
304 307 tracing::warn!(%project_id, error = %e, "linked project items lookup failed; rendering project without releases");
@@ -309,13 +312,13 @@ async fn fetch_linked_releases(
309 312 // Batch-load versions and tags for all items in two queries rather than
310 313 // two per item (the previous N+1). Both helpers return maps keyed by item.
311 314 let item_ids: Vec<_> = db_items.iter().map(|i| i.id).collect();
312 - let mut versions_by_item = db::versions::get_versions_by_items(&state.db, &item_ids)
315 + let mut versions_by_item = db::versions::get_versions_by_items(db, &item_ids)
313 316 .await
314 317 .unwrap_or_else(|e| {
315 318 tracing::warn!(%project_id, error = %e, "batch version load failed; releases will omit versions");
316 319 Default::default()
317 320 });
318 - let tags_by_item = db::tags::get_tags_for_items(&state.db, &item_ids)
321 + let tags_by_item = db::tags::get_tags_for_items(db, &item_ids)
319 322 .await
320 323 .unwrap_or_else(|e| {
321 324 tracing::warn!(%project_id, error = %e, "batch tag load failed; releases will omit tags");
@@ -9,12 +9,15 @@ use axum::{
9 9 use serde::Deserialize;
10 10 use tokio_util::io::ReaderStream;
11 11
12 + use sqlx::PgPool;
13 +
12 14 use crate::{
13 15 auth::MaybeUserVerified,
16 + config::Config,
14 17 constants,
15 18 error::{AppError, Result, ResultExt},
16 19 git,
17 - AppState,
20 + Git,
18 21 };
19 22
20 23 use super::{repos_root, resolve_repo, resolve_repo_name};
@@ -22,11 +25,12 @@ use super::{repos_root, resolve_repo, resolve_repo_name};
22 25 /// `GET /git/{owner}/{repo}/raw/{ref}/{*path}`: raw file download.
23 26 #[tracing::instrument(skip_all, name = "git::raw_file")]
24 27 pub(super) async fn raw_file(
25 - State(state): State<AppState>,
28 + State(db): State<PgPool>,
29 + State(config): State<Config>,
26 30 MaybeUserVerified(maybe_user): MaybeUserVerified,
27 31 Path((owner, repo_name, git_ref, path)): Path<(String, String, String, String)>,
28 32 ) -> Result<Response> {
29 - let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
33 + let resolved = resolve_repo(&db, &config,&owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
30 34
31 35 let git_ref_c = git_ref.clone();
32 36 let path_c = path.clone();
@@ -99,7 +103,8 @@ pub(super) struct InfoRefsQuery {
99 103 /// `GET /git/{owner}/{repo}/info/refs?service=git-upload-pack`
100 104 #[tracing::instrument(skip_all, name = "git::smart_http_info_refs")]
101 105 pub(super) async fn smart_http_info_refs(
102 - State(state): State<AppState>,
106 + State(db): State<PgPool>,
107 + State(config): State<Config>,
103 108 MaybeUserVerified(maybe_user): MaybeUserVerified,
104 109 Path((owner, repo_name)): Path<(String, String)>,
105 110 Query(query): Query<InfoRefsQuery>,
@@ -113,16 +118,16 @@ pub(super) async fn smart_http_info_refs(
113 118 // Resolve session OR personal-access-token (Basic auth) so `git clone`/push
114 119 // of a private repo works from the CLI, not just a logged-in browser.
115 120 let principal = super::resolve_git_http_principal(
116 - &state, &headers, maybe_user.as_ref().map(|u| u.id),
121 + &db, &headers, maybe_user.as_ref().map(|u| u.id),
117 122 ).await;
118 123 let repo_name = resolve_repo_name(&repo_name);
119 - let resolved = resolve_repo(&state, &owner, repo_name, principal.as_ref().map(|p| p.user_id)).await?;
124 + let resolved = resolve_repo(&db, &config,&owner, repo_name, principal.as_ref().map(|p| p.user_id)).await?;
120 125
121 126 // The receive-pack (push) advertisement requires write authorization; the
122 127 // upload-pack (read) advertisement only needs the read access resolve_repo
123 128 // already enforced.
124 129 if service == "git-receive-pack" {
125 - authorize_push(&state, &resolved, principal.as_ref()).await?;
130 + authorize_push(&db, &resolved, principal.as_ref()).await?;
126 131 }
127 132
128 133 // The ref advertisement is NOT gated by `git_smart_http_semaphore`: it's a
@@ -208,7 +213,7 @@ fn require_push_token(
208 213 /// The git CLI always authenticates over HTTP with a PAT, so requiring one does
209 214 /// not regress any real push path.
210 215 async fn authorize_push(
211 - state: &AppState,
216 + db: &PgPool,
212 217 resolved: &super::ResolvedRepo,
213 218 principal: Option<&super::GitHttpPrincipal>,
214 219 ) -> Result<()> {
@@ -216,7 +221,7 @@ async fn authorize_push(
216 221 let is_owner = resolved.db_user.id == principal.user_id;
217 222 if !is_owner {
218 223 let can_push = crate::db::repo_collaborators::can_user_push(
219 - &state.db, resolved.db_repo.id, principal.user_id,
224 + db, resolved.db_repo.id, principal.user_id,
220 225 ).await?;
221 226 if !can_push {
222 227 return Err(AppError::Forbidden);
@@ -228,26 +233,27 @@ async fn authorize_push(
228 233 /// `POST /git/{owner}/{repo}/git-upload-pack`
229 234 #[tracing::instrument(skip_all, name = "git::smart_http_upload_pack")]
230 235 pub(super) async fn smart_http_upload_pack(
231 - State(state): State<AppState>,
236 + State(db): State<PgPool>,
237 + State(config): State<Config>,
238 + State(git): State<Git>,
232 239 MaybeUserVerified(maybe_user): MaybeUserVerified,
233 240 Path((owner, repo_name)): Path<(String, String)>,
234 241 headers: axum::http::HeaderMap,
235 242 body: axum::body::Bytes,
236 243 ) -> Result<Response> {
237 244 let principal = super::resolve_git_http_principal(
238 - &state, &headers, maybe_user.as_ref().map(|u| u.id),
245 + &db, &headers, maybe_user.as_ref().map(|u| u.id),
239 246 ).await;
240 247 let repo_name = resolve_repo_name(&repo_name);
241 - resolve_repo(&state, &owner, repo_name, principal.as_ref().map(|p| p.user_id)).await?;
248 + resolve_repo(&db, &config,&owner, repo_name, principal.as_ref().map(|p| p.user_id)).await?;
242 249
243 - let root = repos_root(&state)?;
250 + let root = repos_root(&config)?;
244 251 let repo_path = git::repo_disk_path(&root, &owner, repo_name)?;
245 252
246 253 // One permit for the whole clone. Held until the child exits (see the reaper
247 254 // task below), so the cap bounds concurrent `upload-pack` processes —
248 255 // previously a burst of clones fanned out unbounded.
249 - let permit = state
250 - .limiters.git_smart_http_semaphore
256 + let permit = git.smart_http_semaphore
251 257 .clone()
252 258 .acquire_owned()
253 259 .await
@@ -318,18 +324,20 @@ pub(super) async fn smart_http_upload_pack(
318 324 /// streamed back out. Mirrors the SSH receive-pack path's authz model.
319 325 #[tracing::instrument(skip_all, name = "git::smart_http_receive_pack")]
320 326 pub(super) async fn smart_http_receive_pack(
321 - State(state): State<AppState>,
327 + State(db): State<PgPool>,
328 + State(config): State<Config>,
329 + State(git): State<Git>,
322 330 MaybeUserVerified(maybe_user): MaybeUserVerified,
323 331 Path((owner, repo_name)): Path<(String, String)>,
324 332 headers: axum::http::HeaderMap,
325 333 body: axum::body::Body,
326 334 ) -> Result<Response> {
327 335 let principal = super::resolve_git_http_principal(
328 - &state, &headers, maybe_user.as_ref().map(|u| u.id),
336 + &db, &headers, maybe_user.as_ref().map(|u| u.id),
329 337 ).await;
330 338 let repo_name = resolve_repo_name(&repo_name);
331 - let resolved = resolve_repo(&state, &owner, repo_name, principal.as_ref().map(|p| p.user_id)).await?;
332 - authorize_push(&state, &resolved, principal.as_ref()).await?;
339 + let resolved = resolve_repo(&db, &config,&owner, repo_name, principal.as_ref().map(|p| p.user_id)).await?;
340 + authorize_push(&db, &resolved, principal.as_ref()).await?;
333 341
334 342 // Per-account disk quota, same guard the SSH push path enforces. The owner
335 343 // dir is the parent of `{root}/{owner}/{repo}.git` — a canonicalized, root-
@@ -342,8 +350,7 @@ pub(super) async fn smart_http_receive_pack(
342 350 .ok_or_else(|| AppError::Internal(anyhow::anyhow!("git repo path has no owner dir")))?;
343 351 git::enforce_disk_quota(owner_dir).await?;
344 352
345 - let permit = state
346 - .limiters.git_smart_http_semaphore
353 + let permit = git.smart_http_semaphore
347 354 .clone()
348 355 .acquire_owned()
349 356 .await
@@ -36,7 +36,7 @@ pub(super) async fn issue_list(
36 36 Path((owner, repo_name)): Path<(String, String)>,
37 37 Query(query): Query<IssueListQuery>,
38 38 ) -> Result<impl IntoResponse> {
39 - let resolved = super::resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
39 + let resolved = super::resolve_repo(&state.db, &state.config, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
40 40
41 41 let status_filter = match query.status.as_deref() {
42 42 Some("closed") => Some(IssueStatus::Closed),
@@ -90,7 +90,7 @@ pub(super) async fn issue_detail(
90 90 MaybeUserVerified(maybe_user): MaybeUserVerified,
91 91 Path((owner, repo_name, number)): Path<(String, String, i32)>,
92 92 ) -> Result<impl IntoResponse> {
93 - let resolved = super::resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
93 + let resolved = super::resolve_repo(&state.db, &state.config, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
94 94
95 95 let issue = db::issues::get_issue_by_number(&state.db, resolved.db_repo.id, number)
96 96 .await?
@@ -29,7 +29,7 @@ pub fn git_issue_routes() -> CsrfRouter<AppState> {
29 29
30 30 /// Get the default branch name for a repo (for nav bar links).
31 31 async fn default_ref(state: &AppState, owner: &str, repo_name: &str) -> String {
32 - let root = match repos_root(state) {
32 + let root = match repos_root(&state.config) {
33 33 Ok(r) => r,
34 34 Err(_) => return "main".to_string(),
35 35 };
@@ -138,7 +138,7 @@ pub(super) async fn process_push(
138 138 // it inline parks a tokio worker thread for the duration (Run #2 Perf SERIOUS).
139 139 // The walk creates and drops all git2 types internally and returns only the
140 140 // owned, Send `Vec` of collected refs.
141 - let root = repos_root(&state)?;
141 + let root = repos_root(&state.config)?;
142 142 let repo_owner = req.repo_owner.clone();
143 143 let repo_name = req.repo_name.clone();
144 144 let after = req.after.clone();
@@ -29,7 +29,7 @@ pub(super) async fn repo_settings_form(
29 29 AuthUser(user): AuthUser,
30 30 Path((owner, repo_name)): Path<(String, String)>,
31 31 ) -> Result<impl IntoResponse> {
32 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
32 + let resolved = super::resolve_repo(&state.db, &state.config, &owner, &repo_name, Some(user.id)).await?;
33 33
34 34 if user.id != resolved.db_user.id {
35 35 return Err(AppError::Forbidden);
@@ -70,7 +70,7 @@ pub(super) async fn repo_settings_save(
70 70 Form(form): Form<RepoSettingsForm>,
71 71 ) -> Result<impl IntoResponse> {
72 72 user.check_not_suspended()?;
73 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
73 + let resolved = super::resolve_repo(&state.db, &state.config, &owner, &repo_name, Some(user.id)).await?;
74 74
75 75 if user.id != resolved.db_user.id {
76 76 return Err(AppError::Forbidden);
@@ -118,7 +118,7 @@ pub(super) async fn repo_settings_delete(
118 118 Path((owner, repo_name)): Path<(String, String)>,
119 119 ) -> Result<impl IntoResponse> {
120 120 user.check_not_suspended()?;
121 - let resolved = super::resolve_repo(&state, &owner, &repo_name, Some(user.id)).await?;
121 + let resolved = super::resolve_repo(&state.db, &state.config, &owner, &repo_name, Some(user.id)).await?;
122 122
123 123 if user.id != resolved.db_user.id {
124 124 return Err(AppError::Forbidden);