Skip to main content

max / makenotwork

G2: repo visibility, resolve_repo refactor, repo creation API, maxmj→max rename - Add visibility column (public/unlisted/private) to git_repos - Refactor all git routes through resolve_repo() helper: DB-driven user lookup, auto-registration, visibility enforcement - Add MaybeUser to smart HTTP handlers so private repos block clones - POST /api/repos creates bare repos on disk from dashboard - PUT /api/repos/{id}/visibility updates repo visibility - Dashboard "Create Repository" UI in project settings tab - Rename /opt/git/maxmj/ to /opt/git/max/, update all references - Update local git remotes from maxmj to max - Onboarding emails, blog scheduling, text editor improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-11 05:24 UTC
Commit: 3fb6820a3708f06eff5966d7035fbe6f369f9202
Parent: 6db86ed
20 files changed, +656 insertions, -89 deletions
@@ -13,7 +13,7 @@ Public code means public scrutiny. Our practices are auditable.
13 13 ## Repository
14 14
15 15 ```
16 - makenot.work/git/maxmj/
16 + makenot.work/git/max/
17 17 ```
18 18
19 19 Available for review:
@@ -170,7 +170,7 @@ All code is publicly available for review. You can:
170 170 - Check for vulnerabilities
171 171 - Report issues responsibly
172 172
173 - Repository: [makenot.work/git/maxmj/](https://makenot.work/git/maxmj/)
173 + Repository: [makenot.work/git/max/](https://makenot.work/git/max/)
174 174
175 175 ---
176 176
@@ -3453,7 +3453,7 @@ dependencies = [
3453 3453
3454 3454 [[package]]
3455 3455 name = "makenotwork"
3456 - version = "0.1.7"
3456 + version = "0.1.9"
3457 3457 dependencies = [
3458 3458 "ammonia",
3459 3459 "anyhow",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.1.8"
3 + version = "0.1.9"
4 4 edition = "2024"
5 5 license-file = "../../LICENSE"
6 6
@@ -2,8 +2,8 @@
2 2 # Setup SSH git push access on the production VPS.
3 3 #
4 4 # Creates a `git` system user with git-shell, home directory at /opt/git/
5 - # so that `git@makenot.work:maxmj/makenotwork.git` resolves to
6 - # /opt/git/maxmj/makenotwork.git.
5 + # so that `git@makenot.work:max/makenotwork.git` resolves to
6 + # /opt/git/max/makenotwork.git.
7 7 #
8 8 # Run as root on the production VPS.
9 9
@@ -62,7 +62,7 @@ echo ""
62 62 echo "=== Git SSH setup complete ==="
63 63 echo ""
64 64 echo "Test with:"
65 - echo " git clone git@makenot.work:maxmj/makenotwork.git"
65 + echo " git clone git@makenot.work:max/makenotwork.git"
66 66 echo " cd makenotwork && git push"
67 67 echo ""
68 68 echo "To add more SSH keys, edit: $GIT_HOME/.ssh/authorized_keys"
@@ -0,0 +1,5 @@
1 + -- Onboarding email drip sequence tracking.
2 + -- step 0 = no emails sent, 1 = welcome sent, 2 = profile tips sent, 3 = stripe guide sent (done).
3 +
4 + ALTER TABLE users ADD COLUMN onboarding_email_step SMALLINT NOT NULL DEFAULT 0;
5 + ALTER TABLE users ADD COLUMN onboarding_email_sent_at TIMESTAMPTZ;
@@ -0,0 +1 @@
1 + ALTER TABLE git_repos ADD COLUMN visibility VARCHAR(16) NOT NULL DEFAULT 'public';
@@ -6,7 +6,7 @@ use super::models::DbGitRepo;
6 6 use super::{GitRepoId, ProjectId, UserId};
7 7 use crate::error::Result;
8 8
9 - /// Register a new git repository for a user.
9 + /// Register a new git repository for a user (default visibility: public).
10 10 pub async fn create_repo(pool: &PgPool, user_id: UserId, name: &str) -> Result<DbGitRepo> {
11 11 let repo = sqlx::query_as::<_, DbGitRepo>(
12 12 r#"
@@ -23,6 +23,29 @@ pub async fn create_repo(pool: &PgPool, user_id: UserId, name: &str) -> Result<D
23 23 Ok(repo)
24 24 }
25 25
26 + /// Register a new git repository with explicit visibility.
27 + pub async fn create_repo_with_visibility(
28 + pool: &PgPool,
29 + user_id: UserId,
30 + name: &str,
31 + visibility: &str,
32 + ) -> Result<DbGitRepo> {
33 + let repo = sqlx::query_as::<_, DbGitRepo>(
34 + r#"
35 + INSERT INTO git_repos (user_id, name, visibility)
36 + VALUES ($1, $2, $3)
37 + RETURNING *
38 + "#,
39 + )
40 + .bind(user_id)
41 + .bind(name)
42 + .bind(visibility)
43 + .fetch_one(pool)
44 + .await?;
45 +
46 + Ok(repo)
47 + }
48 +
26 49 /// Look up a repo by its owning user and bare name. Returns `None` if not found.
27 50 pub async fn get_repo_by_user_and_name(
28 51 pool: &PgPool,
@@ -91,3 +114,18 @@ pub async fn unlink_repo_from_project(pool: &PgPool, repo_id: GitRepoId) -> Resu
91 114
92 115 Ok(())
93 116 }
117 +
118 + /// Update the visibility of a repo.
119 + pub async fn update_visibility(
120 + pool: &PgPool,
121 + repo_id: GitRepoId,
122 + visibility: &str,
123 + ) -> Result<()> {
124 + sqlx::query("UPDATE git_repos SET visibility = $2 WHERE id = $1")
125 + .bind(repo_id)
126 + .bind(visibility)
127 + .execute(pool)
128 + .await?;
129 +
130 + Ok(())
131 + }
@@ -134,6 +134,11 @@ pub struct DbUser {
134 134 pub notify_release: bool,
135 135 /// When the creator last sent a broadcast email (rate limiting).
136 136 pub last_broadcast_at: Option<DateTime<Utc>>,
137 + // Onboarding email drip
138 + /// Current step in the getting-started email sequence (0 = none sent, 3 = complete).
139 + pub onboarding_email_step: i16,
140 + /// When the last onboarding email was sent.
141 + pub onboarding_email_sent_at: Option<DateTime<Utc>>,
137 142 }
138 143
139 144 impl DbUser {
@@ -196,6 +201,8 @@ pub struct DbGitRepo {
196 201 pub project_id: Option<ProjectId>,
197 202 /// When the repo was registered.
198 203 pub created_at: DateTime<Utc>,
204 + /// Visibility: "public", "unlisted", or "private".
205 + pub visibility: String,
199 206 }
200 207
201 208 /// A purchasable or free item within a project.
@@ -1265,6 +1272,8 @@ mod tests {
1265 1272 notify_follower: true,
1266 1273 notify_release: true,
1267 1274 last_broadcast_at: None,
1275 + onboarding_email_step: 0,
1276 + onboarding_email_sent_at: None,
1268 1277 }
1269 1278 }
1270 1279
@@ -485,6 +485,41 @@ pub async fn set_upload_trusted(pool: &PgPool, user_id: UserId, trusted: bool) -
485 485 Ok(())
486 486 }
487 487
488 + // ── Onboarding email drip ──
489 +
490 + /// Users who need the next onboarding email. Returns users at a given step
491 + /// whose last email was sent more than `min_age` ago (or never).
492 + pub async fn get_onboarding_candidates(
493 + pool: &PgPool,
494 + step: i16,
495 + min_age: chrono::Duration,
496 + ) -> Result<Vec<DbUser>> {
497 + let cutoff = chrono::Utc::now() - min_age;
498 + let users = sqlx::query_as::<_, DbUser>(
499 + "SELECT * FROM users
500 + WHERE onboarding_email_step = $1
501 + AND (onboarding_email_sent_at IS NULL OR onboarding_email_sent_at < $2)
502 + AND suspended_at IS NULL",
503 + )
504 + .bind(step)
505 + .bind(cutoff)
506 + .fetch_all(pool)
507 + .await?;
508 + Ok(users)
509 + }
510 +
511 + /// Advance a user's onboarding email step and record the send time.
512 + pub async fn advance_onboarding_step(pool: &PgPool, user_id: UserId, new_step: i16) -> Result<()> {
513 + sqlx::query(
514 + "UPDATE users SET onboarding_email_step = $2, onboarding_email_sent_at = NOW() WHERE id = $1",
515 + )
516 + .bind(user_id)
517 + .bind(new_step)
518 + .execute(pool)
519 + .await?;
520 + Ok(())
521 + }
522 +
488 523 /// Disconnect a user's Stripe account
489 524 pub async fn disconnect_user_stripe(pool: &PgPool, user_id: UserId) -> Result<DbUser> {
490 525 let user = sqlx::query_as::<_, DbUser>(
@@ -561,6 +561,96 @@ Check it out: {url}
561 561 self.send_email(to, subject, body).await
562 562 }
563 563
564 + // ── Onboarding email sequence ──
565 +
566 + /// Step 1: Welcome email sent immediately after signup.
567 + pub async fn send_onboarding_welcome(
568 + &self,
569 + to_email: &str,
570 + to_name: Option<&str>,
571 + host_url: &str,
572 + ) -> Result<()> {
573 + let subject = "Welcome to Makenot.work";
574 + let body = format!(
575 + r#"Hi{name},
576 +
577 + Welcome to Makenot.work — the fair creator platform.
578 +
579 + Here's what makes us different:
580 + - 0% platform fee. You keep everything (minus Stripe's ~3% processing).
581 + - No lock-in. Export your data, cancel anytime.
582 + - Source-available. You can see how everything works.
583 +
584 + To get started, head to your dashboard:
585 + {host_url}/dashboard
586 +
587 + You'll find a checklist there to help you set up your profile, connect Stripe, create your first project, and publish your first item.
588 +
589 + If you have questions, reply to this email — it goes straight to a human.
590 +
591 + - Makenotwork"#,
592 + name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(),
593 + host_url = host_url,
594 + );
595 +
596 + self.send_email(to_email, subject, &body).await
597 + }
598 +
599 + /// Step 2: Profile setup tips (sent ~24h after signup).
600 + pub async fn send_onboarding_profile(
601 + &self,
602 + to_email: &str,
603 + to_name: Option<&str>,
604 + host_url: &str,
605 + ) -> Result<()> {
606 + let subject = "Set up your creator profile";
607 + let body = format!(
608 + r#"Hi{name},
609 +
610 + A quick tip: creators with a display name and bio get more attention from fans.
611 +
612 + Head to your account settings to add them:
613 + {host_url}/dashboard (Account tab)
614 +
615 + Your display name appears on all your projects and items. Your bio tells fans who you are and what you create.
616 +
617 + Once your profile is set, create your first project — that's where your items live.
618 +
619 + - Makenotwork"#,
620 + name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(),
621 + host_url = host_url,
622 + );
623 +
624 + self.send_email(to_email, subject, &body).await
625 + }
626 +
627 + /// Step 3: Stripe connection guide (sent ~72h after signup).
628 + pub async fn send_onboarding_stripe(
629 + &self,
630 + to_email: &str,
631 + to_name: Option<&str>,
632 + host_url: &str,
633 + ) -> Result<()> {
634 + let subject = "Start receiving payments";
635 + let body = format!(
636 + r#"Hi{name},
637 +
638 + To sell on Makenot.work, connect your Stripe account. It takes about 5 minutes.
639 +
640 + {host_url}/dashboard (Payments tab)
641 +
642 + Stripe handles payment processing at ~3% per transaction. That's the only fee — Makenot.work takes 0%.
643 +
644 + Once connected, you can set prices on your items and start selling immediately.
645 +
646 + - Makenotwork"#,
647 + name = to_name.map(|n| format!(" {}", n)).unwrap_or_default(),
648 + host_url = host_url,
649 + );
650 +
651 + self.send_email(to_email, subject, &body).await
652 + }
653 +
564 654 /// Send an email (implementation)
565 655 async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> {
566 656 self.send_email_inner(to, subject, body, &[], None).await
@@ -171,7 +171,9 @@ pub fn api_routes() -> Router<AppState> {
171 171 .route("/api/projects", post(projects::create_project))
172 172 .route("/api/projects/{id}", put(projects::update_project))
173 173 .route("/api/projects/{id}", delete(projects::delete_project))
174 - // Git repo linking
174 + // Git repo management
175 + .route("/api/repos", post(projects::create_repo))
176 + .route("/api/repos/{id}/visibility", put(projects::update_repo_visibility))
175 177 .route("/api/projects/{id}/repos", post(projects::link_repo))
176 178 .route("/api/projects/{id}/repos/{repo_name}", delete(projects::unlink_repo))
177 179 // Item routes
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
10 10
11 11 use crate::{
12 12 auth::AuthUser,
13 - db::{self, ProjectId, Slug},
13 + db::{self, GitRepoId, ProjectId, Slug},
14 14 error::{AppError, Result},
15 15 helpers::{hx_toast, is_htmx_request},
16 16 types::ListResponse,
@@ -270,3 +270,126 @@ pub(super) async fn unlink_repo(
270 270 Html(String::new()),
271 271 ))
272 272 }
273 +
274 + // =============================================================================
275 + // Git Repo Creation + Visibility
276 + // =============================================================================
277 +
278 + /// JSON input for creating a bare repo on disk.
279 + #[derive(Debug, Deserialize)]
280 + pub struct CreateRepoRequest {
281 + pub name: String,
282 + pub visibility: Option<String>,
283 + }
284 +
285 + /// JSON response representing a git repo.
286 + #[derive(Debug, Serialize)]
287 + pub struct RepoResponse {
288 + pub id: GitRepoId,
289 + pub name: String,
290 + pub visibility: String,
291 + }
292 +
293 + /// Create a bare git repo on disk and register it in the DB.
294 + #[tracing::instrument(skip_all, name = "projects::create_repo")]
295 + pub(super) async fn create_repo(
296 + State(state): State<AppState>,
297 + AuthUser(user): AuthUser,
298 + Json(req): Json<CreateRepoRequest>,
299 + ) -> Result<impl IntoResponse> {
300 + user.check_not_suspended()?;
301 +
302 + // Validate repo name: alphanumeric, hyphens, underscores, dots (reuse git segment rules)
303 + let name = req.name.trim();
304 + if name.is_empty() || name.len() > 64 {
305 + return Err(AppError::Validation("Repository name must be 1-64 characters".to_string()));
306 + }
307 + if name.starts_with('.') || name == ".." {
308 + return Err(AppError::Validation("Invalid repository name".to_string()));
309 + }
310 + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') {
311 + return Err(AppError::Validation(
312 + "Repository name may only contain letters, numbers, hyphens, underscores, and dots".to_string(),
313 + ));
314 + }
315 +
316 + // Validate visibility
317 + let visibility = req.visibility.as_deref().unwrap_or("public");
318 + if !matches!(visibility, "public" | "unlisted" | "private") {
319 + return Err(AppError::Validation("Visibility must be public, unlisted, or private".to_string()));
320 + }
321 +
322 + // Need git_repos_path configured
323 + let git_root = state
324 + .config
325 + .git_repos_path
326 + .as_deref()
327 + .ok_or_else(|| AppError::Validation("Git repositories are not configured on this server".to_string()))?;
328 +
329 + // Check repo doesn't already exist in DB
330 + if db::git_repos::get_repo_by_user_and_name(&state.db, user.id, name).await?.is_some() {
331 + return Err(AppError::Validation("A repository with that name already exists".to_string()));
332 + }
333 +
334 + // Create bare repo on disk: {git_root}/{username}/{name}.git
335 + let username = user.username.to_string();
336 + let owner_dir = std::path::Path::new(git_root).join(&username);
337 + let repo_dir = owner_dir.join(format!("{name}.git"));
338 +
339 + if repo_dir.exists() {
340 + return Err(AppError::Validation("A repository with that name already exists on disk".to_string()));
341 + }
342 +
343 + std::fs::create_dir_all(&owner_dir)
344 + .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to create owner directory: {e}")))?;
345 +
346 + git2::Repository::init_bare(&repo_dir)
347 + .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to init bare repo: {e}")))?;
348 +
349 + // Register in DB
350 + let db_repo = db::git_repos::create_repo_with_visibility(&state.db, user.id, name, visibility).await?;
351 +
352 + Ok(Json(RepoResponse {
353 + id: db_repo.id,
354 + name: db_repo.name,
355 + visibility: db_repo.visibility,
356 + }))
357 + }
358 +
359 + /// JSON input for updating repo visibility.
360 + #[derive(Debug, Deserialize)]
361 + pub struct UpdateRepoVisibilityRequest {
362 + pub visibility: String,
363 + }
364 +
365 + /// Update a repo's visibility. The repo must be owned by the authenticated user.
366 + #[tracing::instrument(skip_all, name = "projects::update_repo_visibility")]
367 + pub(super) async fn update_repo_visibility(
368 + State(state): State<AppState>,
369 + AuthUser(user): AuthUser,
370 + Path(repo_id): Path<GitRepoId>,
371 + Json(req): Json<UpdateRepoVisibilityRequest>,
372 + ) -> Result<impl IntoResponse> {
373 + user.check_not_suspended()?;
374 +
375 + let repo = db::git_repos::get_repos_by_user(&state.db, user.id)
376 + .await?
377 + .into_iter()
378 + .find(|r| r.id == repo_id)
379 + .ok_or(AppError::NotFound)?;
380 +
381 + if repo.user_id != user.id {
382 + return Err(AppError::Forbidden);
383 + }
384 +
385 + if !matches!(req.visibility.as_str(), "public" | "unlisted" | "private") {
386 + return Err(AppError::Validation("Visibility must be public, unlisted, or private".to_string()));
387 + }
388 +
389 + db::git_repos::update_visibility(&state.db, repo_id, &req.visibility).await?;
390 +
391 + Ok((
392 + [("HX-Trigger", hx_toast("Visibility updated", "success"))],
393 + Html(String::new()),
394 + ))
395 + }
@@ -396,6 +396,8 @@ async fn join_handler(
396 396 );
397 397
398 398 let email_client = state.email.clone();
399 + let welcome_host_url = state.config.host_url.clone();
400 + let welcome_db = state.db.clone();
399 401 tokio::spawn(async move {
400 402 if let Err(e) = email_client
401 403 .send_verification(&user_email, user_display_name.as_deref(), &verify_url)
@@ -403,6 +405,14 @@ async fn join_handler(
403 405 {
404 406 tracing::error!(error = ?e, "failed to send verification email");
405 407 }
408 + // Send welcome email and advance onboarding step
409 + if let Err(e) = email_client
410 + .send_onboarding_welcome(&user_email, user_display_name.as_deref(), &welcome_host_url)
411 + .await
412 + {
413 + tracing::error!(error = ?e, "failed to send welcome email");
414 + }
415 + let _ = db::users::advance_onboarding_step(&welcome_db, user_id, 1).await;
406 416 });
407 417
408 418 // For HTMX requests, return redirect header (JS will intercept and show success)
@@ -8,13 +8,14 @@ use axum::{
8 8 routing::{get, post},
9 9 Router,
10 10 };
11 + use git2::Repository;
11 12 use serde::Deserialize;
12 13 use tower_sessions::Session;
13 14
14 15 use crate::{
15 16 auth::MaybeUser,
16 17 constants,
17 - db::{self, Username},
18 + db::{self, DbGitRepo, DbUser, UserId, Username},
18 19 error::{AppError, Result},
19 20 git,
20 21 helpers::get_csrf_token,
@@ -108,27 +109,74 @@ fn parent_of(path: &str) -> String {
108 109 }
109 110
110 111 // ============================================================================
111 - // Auto-registration
112 + // Repo resolution + visibility
112 113 // ============================================================================
113 114
114 - /// Ensure a disk-visible repo has a row in `git_repos`.
115 - /// Called on first visit — if the repo exists on disk but not in the DB, insert it.
116 - async fn auto_register_repo(state: &AppState, owner: &str, repo_name: &str) {
115 + /// Result of resolving a git repo from the URL: DB record, opened git2 repo, and owner user.
116 + #[allow(dead_code)]
117 + struct ResolvedRepo {
118 + db_repo: DbGitRepo,
119 + git_repo: Repository,
120 + db_user: DbUser,
121 + }
122 +
123 + /// Resolve a repo from the URL `{owner}/{repo_name}`:
124 + /// 1. Look up user by username
125 + /// 2. Look up `git_repos` row (auto-register if missing but exists on disk)
126 + /// 3. Check visibility against the session user
127 + /// 4. Open the repo from disk
128 + fn resolve_repo_name(repo_name: &str) -> &str {
129 + repo_name.strip_suffix(".git").unwrap_or(repo_name)
130 + }
131 +
132 + async fn resolve_repo(
133 + state: &AppState,
134 + owner: &str,
135 + repo_name: &str,
136 + session_user_id: Option<UserId>,
137 + ) -> Result<ResolvedRepo> {
138 + let root = repos_root(state)?;
139 +
140 + // 1. Look up user by URL owner
117 141 let username = Username::from_trusted(owner.to_string());
118 - let db_user = match db::users::get_user_by_username(&state.db, &username).await {
119 - Ok(Some(u)) => u,
120 - _ => return,
142 + let db_user = db::users::get_user_by_username(&state.db, &username)
143 + .await?
144 + .ok_or(AppError::NotFound)?;
145 +
146 + // 2. Look up repo in DB
147 + let db_repo = match db::git_repos::get_repo_by_user_and_name(&state.db, db_user.id, repo_name).await? {
148 + Some(r) => r,
149 + None => {
150 + // Auto-register: repo exists on disk but not in DB
151 + // Try opening from disk first to verify it exists
152 + let disk_dir = owner;
153 + if git::open_repo(&root, disk_dir, repo_name).is_err() {
154 + return Err(AppError::NotFound);
155 + }
156 + match db::git_repos::create_repo(&state.db, db_user.id, repo_name).await {
157 + Ok(r) => r,
158 + Err(e) => {
159 + tracing::debug!(owner, repo_name, error = ?e, "auto-register failed, retrying lookup");
160 + // Race condition: another request registered it first
161 + db::git_repos::get_repo_by_user_and_name(&state.db, db_user.id, repo_name)
162 + .await?
163 + .ok_or(AppError::NotFound)?
164 + }
165 + }
166 + }
121 167 };
122 168
123 - // Already registered?
124 - if let Ok(Some(_)) = db::git_repos::get_repo_by_user_and_name(&state.db, db_user.id, repo_name).await {
125 - return;
169 + // 3. Check visibility
170 + let is_owner = session_user_id == Some(db_user.id);
171 + if db_repo.visibility == "private" && !is_owner {
172 + return Err(AppError::NotFound);
126 173 }
174 + // "unlisted" and "public" are both accessible via direct URL
127 175
128 - // Not registered — insert (no project link)
129 - if let Err(e) = db::git_repos::create_repo(&state.db, db_user.id, repo_name).await {
130 - tracing::debug!(owner, repo_name, error = ?e, "auto-register repo failed (may already exist)");
131 - }
176 + // 4. Open repo from disk
177 + let git_repo = git::open_repo(&root, owner, repo_name)?;
178 +
179 + Ok(ResolvedRepo { db_repo, git_repo, db_user })
132 180 }
133 181
134 182 // ============================================================================
@@ -138,21 +186,8 @@ async fn auto_register_repo(state: &AppState, owner: &str, repo_name: &str) {
138 186 /// Look up the project linked to a git repo and fetch its public items with versions.
139 187 async fn fetch_linked_releases(
140 188 state: &AppState,
141 - owner: &str,
142 - repo_name: &str,
189 + db_repo: &DbGitRepo,
143 190 ) -> (Option<Project>, Vec<ReleaseItem>) {
144 - let username = Username::from_trusted(owner.to_string());
145 - let db_user = match db::users::get_user_by_username(&state.db, &username).await {
146 - Ok(Some(u)) => u,
147 - _ => return (None, Vec::new()),
148 - };
149 -
150 - // Look up via git_repos table
151 - let db_repo = match db::git_repos::get_repo_by_user_and_name(&state.db, db_user.id, repo_name).await {
152 - Ok(Some(r)) => r,
153 - _ => return (None, Vec::new()),
154 - };
155 -
156 191 let project_id = match db_repo.project_id {
157 192 Some(id) => id,
158 193 None => return (None, Vec::new()),
@@ -199,22 +234,17 @@ async fn repo_overview(
199 234 MaybeUser(maybe_user): MaybeUser,
200 235 Path((owner, repo_name)): Path<(String, String)>,
201 236 ) -> Result<impl IntoResponse> {
202 - let root = repos_root(&state)?;
203 - let repo = git::open_repo(&root, &owner, &repo_name)?;
204 - let info = git::repo_info(&repo, &repo_name);
205 - let refs = git::list_refs(&repo);
237 + let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
238 + let info = git::repo_info(&resolved.git_repo, &repo_name);
239 + let refs = git::list_refs(&resolved.git_repo);
206 240
207 - let commit_oid = git::resolve_ref(&repo, &info.default_branch)?;
208 - let tree_items = git::list_tree(&repo, commit_oid, "")?;
209 - let readme_html = git::find_readme(&repo, commit_oid);
241 + let commit_oid = git::resolve_ref(&resolved.git_repo, &info.default_branch)?;
242 + let tree_items = git::list_tree(&resolved.git_repo, commit_oid, "")?;
243 + let readme_html = git::find_readme(&resolved.git_repo, commit_oid);
210 244
211 245 let csrf_token = get_csrf_token(&session).await;
212 246
213 - // Auto-register repo in DB if it exists on disk but isn't tracked yet
214 - auto_register_repo(&state, &owner, &repo_name).await;
215 -
216 - // Look up linked project + releases
217 - let (linked_project, release_items) = fetch_linked_releases(&state, &owner, &repo_name).await;
247 + let (linked_project, release_items) = fetch_linked_releases(&state, &resolved.db_repo).await;
218 248
219 249 Ok(GitRepoTemplate {
220 250 csrf_token,
@@ -240,17 +270,15 @@ async fn tree_root(
240 270 MaybeUser(maybe_user): MaybeUser,
241 271 Path((owner, repo_name, git_ref)): Path<(String, String, String)>,
242 272 ) -> Result<impl IntoResponse> {
243 - let root = repos_root(&state)?;
244 - let repo = git::open_repo(&root, &owner, &repo_name)?;
245 - let refs = git::list_refs(&repo);
246 - let commit_oid = git::resolve_ref(&repo, &git_ref)?;
247 - let tree_items = git::list_tree(&repo, commit_oid, "")?;
248 - let readme_html = git::find_readme(&repo, commit_oid);
273 + let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
274 + let refs = git::list_refs(&resolved.git_repo);
275 + let commit_oid = git::resolve_ref(&resolved.git_repo, &git_ref)?;
276 + let tree_items = git::list_tree(&resolved.git_repo, commit_oid, "")?;
277 + let readme_html = git::find_readme(&resolved.git_repo, commit_oid);
249 278
250 279 let csrf_token = get_csrf_token(&session).await;
251 280
252 - // Look up linked project + releases
253 - let (linked_project, release_items) = fetch_linked_releases(&state, &owner, &repo_name).await;
281 + let (linked_project, release_items) = fetch_linked_releases(&state, &resolved.db_repo).await;
254 282
255 283 Ok(GitRepoTemplate {
256 284 csrf_token,
@@ -276,13 +304,12 @@ async fn tree_or_file(
276 304 MaybeUser(maybe_user): MaybeUser,
277 305 Path((owner, repo_name, git_ref, path)): Path<(String, String, String, String)>,
278 306 ) -> Result<Response> {
279 - let root = repos_root(&state)?;
280 - let repo = git::open_repo(&root, &owner, &repo_name)?;
281 - let commit_oid = git::resolve_ref(&repo, &git_ref)?;
307 + let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
308 + let commit_oid = git::resolve_ref(&resolved.git_repo, &git_ref)?;
282 309
283 310 // Try as directory first
284 - if let Ok(tree_items) = git::list_tree(&repo, commit_oid, &path) {
285 - let refs = git::list_refs(&repo);
311 + if let Ok(tree_items) = git::list_tree(&resolved.git_repo, commit_oid, &path) {
312 + let refs = git::list_refs(&resolved.git_repo);
286 313 let breadcrumbs = build_breadcrumbs(&path);
287 314 let parent_path = parent_of(&path);
288 315 let csrf_token = get_csrf_token(&session).await;
@@ -305,7 +332,7 @@ async fn tree_or_file(
305 332 }
306 333
307 334 // Try as file
308 - let file_content = git::read_file(&repo, commit_oid, &path)?;
335 + let file_content = git::read_file(&resolved.git_repo, commit_oid, &path)?;
309 336 let filename = path
310 337 .rsplit('/')
311 338 .next()
@@ -367,17 +394,16 @@ async fn commit_log(
367 394 Path((owner, repo_name, git_ref)): Path<(String, String, String)>,
368 395 Query(query): Query<CommitQuery>,
369 396 ) -> Result<impl IntoResponse> {
370 - let root = repos_root(&state)?;
371 - let repo = git::open_repo(&root, &owner, &repo_name)?;
372 - let refs = git::list_refs(&repo);
373 - let commit_oid = git::resolve_ref(&repo, &git_ref)?;
397 + let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
398 + let refs = git::list_refs(&resolved.git_repo);
399 + let commit_oid = git::resolve_ref(&resolved.git_repo, &git_ref)?;
374 400
375 401 let page = query.page.unwrap_or(1).max(1);
376 402 let limit = constants::GIT_COMMITS_PER_PAGE;
377 403 let offset = (page - 1) * limit;
378 404
379 405 // Fetch one extra to detect if there are more pages
380 - let commits = git::commit_log(&repo, commit_oid, limit + 1, offset)?;
406 + let commits = git::commit_log(&resolved.git_repo, commit_oid, limit + 1, offset)?;
381 407 let has_more = commits.len() > limit;
382 408 let commits: Vec<_> = commits.into_iter().take(limit).collect();
383 409
@@ -400,21 +426,21 @@ async fn commit_log(
400 426 #[tracing::instrument(skip_all, name = "git::raw_file")]
401 427 async fn raw_file(
402 428 State(state): State<AppState>,
429 + MaybeUser(maybe_user): MaybeUser,
403 430 Path((owner, repo_name, git_ref, path)): Path<(String, String, String, String)>,
404 431 ) -> Result<Response> {
405 - let root = repos_root(&state)?;
406 - let repo = git::open_repo(&root, &owner, &repo_name)?;
407 - let commit_oid = git::resolve_ref(&repo, &git_ref)?;
432 + let resolved = resolve_repo(&state, &owner, &repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
433 + let commit_oid = git::resolve_ref(&resolved.git_repo, &git_ref)?;
408 434
409 435 // Read the raw blob directly
410 - let commit = repo
436 + let commit = resolved.git_repo
411 437 .find_commit(commit_oid)
412 438 .map_err(|_| AppError::NotFound)?;
413 439 let tree = commit.tree().map_err(|_| AppError::NotFound)?;
414 440 let entry = tree
415 441 .get_path(std::path::Path::new(&path))
416 442 .map_err(|_| AppError::NotFound)?;
417 - let obj = entry.to_object(&repo).map_err(|_| AppError::NotFound)?;
443 + let obj = entry.to_object(&resolved.git_repo).map_err(|_| AppError::NotFound)?;
418 444 let blob = obj.as_blob().ok_or(AppError::NotFound)?;
419 445 let content = blob.content().to_vec();
420 446
@@ -460,6 +486,7 @@ struct InfoRefsQuery {
460 486 #[tracing::instrument(skip_all, name = "git::smart_http_info_refs")]
461 487 async fn smart_http_info_refs(
462 488 State(state): State<AppState>,
489 + MaybeUser(maybe_user): MaybeUser,
463 490 Path((owner, repo_name)): Path<(String, String)>,
464 491 Query(query): Query<InfoRefsQuery>,
465 492 ) -> Result<Response> {
@@ -468,7 +495,10 @@ async fn smart_http_info_refs(
468 495 return Err(AppError::Forbidden);
469 496 }
470 497
471 - let repo_name = repo_name.strip_suffix(".git").unwrap_or(&repo_name);
498 + let repo_name = resolve_repo_name(&repo_name);
499 + // Resolve for visibility check (will 404 if private + not owner)
500 + resolve_repo(&state, &owner, repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
501 +
472 502 let root = repos_root(&state)?;
473 503 let repo_path = git::repo_disk_path(&root, &owner, repo_name)?;
474 504
@@ -512,10 +542,14 @@ async fn smart_http_info_refs(
512 542 #[tracing::instrument(skip_all, name = "git::smart_http_upload_pack")]
513 543 async fn smart_http_upload_pack(
514 544 State(state): State<AppState>,
545 + MaybeUser(maybe_user): MaybeUser,
515 546 Path((owner, repo_name)): Path<(String, String)>,
516 547 body: axum::body::Bytes,
517 548 ) -> Result<Response> {
518 - let repo_name = repo_name.strip_suffix(".git").unwrap_or(&repo_name);
549 + let repo_name = resolve_repo_name(&repo_name);
550 + // Resolve for visibility check (will 404 if private + not owner)
551 + resolve_repo(&state, &owner, repo_name, maybe_user.as_ref().map(|u| u.id)).await?;
552 +
519 553 let root = repos_root(&state)?;
520 554 let repo_path = git::repo_disk_path(&root, &owner, repo_name)?;
521 555
@@ -65,6 +65,59 @@ pub async fn send_release_announcements(state: &AppState, item: &DbItem) {
65 65 }
66 66 }
67 67
68 + /// Process the getting-started email drip sequence.
69 + ///
70 + /// Step 1 (welcome) is sent at signup in the auth handler.
71 + /// Step 2 (profile tips) fires 24h after welcome, skipped if display_name is set.
72 + /// Step 3 (Stripe guide) fires 72h after welcome, skipped if Stripe is connected.
73 + async fn send_onboarding_emails(state: &AppState) {
74 + let host_url = &state.config.host_url;
75 +
76 + // Step 1→2: profile tips (24h after welcome)
77 + if let Ok(users) =
78 + db::users::get_onboarding_candidates(&state.db, 1, chrono::Duration::hours(24)).await
79 + {
80 + for user in users {
81 + // Skip if they already set a display name
82 + if user.display_name.is_some() {
83 + let _ = db::users::advance_onboarding_step(&state.db, user.id, 2).await;
84 + continue;
85 + }
86 + if let Err(e) = state
87 + .email
88 + .send_onboarding_profile(&user.email, user.display_name.as_deref(), host_url)
89 + .await
90 + {
91 + tracing::error!(error = ?e, user_id = %user.id, "failed to send onboarding profile email");
92 + continue;
93 + }
94 + let _ = db::users::advance_onboarding_step(&state.db, user.id, 2).await;
95 + }
96 + }
97 +
98 + // Step 2→3: Stripe guide (72h after welcome)
99 + if let Ok(users) =
100 + db::users::get_onboarding_candidates(&state.db, 2, chrono::Duration::hours(48)).await
101 + {
102 + for user in users {
103 + // Skip if they already connected Stripe
104 + if user.stripe_account_id.is_some() {
105 + let _ = db::users::advance_onboarding_step(&state.db, user.id, 3).await;
106 + continue;
107 + }
108 + if let Err(e) = state
109 + .email
110 + .send_onboarding_stripe(&user.email, user.display_name.as_deref(), host_url)
111 + .await
112 + {
113 + tracing::error!(error = ?e, user_id = %user.id, "failed to send onboarding stripe email");
114 + continue;
115 + }
116 + let _ = db::users::advance_onboarding_step(&state.db, user.id, 3).await;
117 + }
118 + }
119 + }
120 +
68 121 /// Spawn the background scheduler loop. Drop `shutdown_tx` to stop it.
69 122 pub fn spawn_scheduler(
70 123 state: AppState,
@@ -104,6 +157,9 @@ pub fn spawn_scheduler(
104 157 }
105 158 }
106 159
160 + // Send onboarding drip emails
161 + send_onboarding_emails(&state).await;
162 +
107 163 // Publish scheduled blog posts
108 164 match db::blog_posts::publish_scheduled_blog_posts(&state.db).await {
109 165 Ok(posts) => {
@@ -106,4 +106,34 @@ function saveTextContent() {
106 106 }
107 107
108 108 document.getElementById('text-body').addEventListener('input', updateWordCount);
109 +
110 + // Auto-save: debounce text body changes (30s after last keystroke)
111 + var autoSaveTimer = null;
112 + var autoSaveStatus = document.getElementById('text-save-status');
113 +
114 + document.getElementById('text-body').addEventListener('input', function() {
115 + clearTimeout(autoSaveTimer);
116 + autoSaveTimer = setTimeout(function() {
117 + autoSaveStatus.innerHTML = '<span class="save-status" style="opacity: 0.5;">Saving...</span>';
118 + var body = document.getElementById('text-body').value;
119 + fetch('/api/items/{{ item.id }}/text', {
120 + method: 'PUT',
121 + headers: { 'Content-Type': 'application/json' },
122 + body: JSON.stringify({ body: body })
123 + })
124 + .then(function(res) { return res.json(); })
125 + .then(function(data) {
126 + autoSaveStatus.innerHTML = '<span class="save-status success">Auto-saved</span>';
127 + if (data.word_count !== undefined) {
128 + document.getElementById('word-count').textContent = data.word_count + ' words';
129 + }
130 + setTimeout(function() {
131 + if (autoSaveStatus.textContent === 'Auto-saved') autoSaveStatus.innerHTML = '';
132 + }, 3000);
133 + })
134 + .catch(function() {
135 + autoSaveStatus.innerHTML = '<span class="save-status error">Auto-save failed</span>';
136 + });
137 + }, 30000);
138 + });
109 139 </script>
@@ -159,6 +159,42 @@
159 159 });
160 160 }
161 161
162 + // Auto-save: debounce blog post changes (30s after last keystroke, edit mode only)
163 + var blogAutoSaveTimer = null;
164 + var postStatus = document.getElementById('post-status');
165 +
166 + function setupBlogAutoSave() {
167 + ['post-title', 'post-slug', 'post-body'].forEach(function(id) {
168 + document.getElementById(id).addEventListener('input', function() {
169 + if (!editingPostId) return;
170 + clearTimeout(blogAutoSaveTimer);
171 + blogAutoSaveTimer = setTimeout(function() {
172 + var title = document.getElementById('post-title').value.trim();
173 + var slug = document.getElementById('post-slug').value.trim();
174 + var body = document.getElementById('post-body').value;
175 + if (!title || !slug) return;
176 + postStatus.innerHTML = '<span style="opacity: 0.5;">Saving...</span>';
177 + fetch('/api/blog/' + editingPostId, {
178 + method: 'PUT',
179 + headers: { 'Content-Type': 'application/json' },
180 + body: JSON.stringify({ title: title, slug: slug, body_markdown: body, is_published: false })
181 + })
182 + .then(function(res) {
183 + if (!res.ok) throw new Error('Auto-save failed');
184 + postStatus.innerHTML = '<span style="color: var(--text-muted);">Auto-saved</span>';
185 + setTimeout(function() {
186 + if (postStatus.textContent === 'Auto-saved') postStatus.innerHTML = '';
187 + }, 3000);
188 + })
189 + .catch(function() {
190 + postStatus.innerHTML = '<span style="color: #c0392b;">Auto-save failed</span>';
191 + });
192 + }, 30000);
193 + });
194 + });
195 + }
196 + setupBlogAutoSave();
197 +
162 198 function saveBlogPost(publish) {
163 199 var title = document.getElementById('post-title').value.trim();
164 200 var slug = document.getElementById('post-slug').value.trim();
@@ -84,8 +84,50 @@
84 84 })();
85 85 </script>
86 86 {% else if linked_repos.is_empty() %}
87 - <p style="opacity: 0.7; text-align: left;">No repositories available. Repositories are automatically registered when visited via <code>/git/username/repo</code>.</p>
87 + <p style="opacity: 0.7; text-align: left;">No repositories available. Create one below or push to the server.</p>
88 88 {% endif %}
89 +
90 + <div style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
91 + <label for="create-repo-name">Create Repository</label>
92 + <div style="display: flex; gap: 0.5rem; align-items: center;">
93 + <input type="text" id="create-repo-name" placeholder="repo-name" style="flex: 1;" pattern="[a-zA-Z0-9._-]+" title="Letters, numbers, hyphens, underscores, and dots">
94 + <button class="secondary" id="create-repo-btn" style="white-space: nowrap;">Create</button>
95 + </div>
96 + <div class="hint">Creates a bare git repository on the server.</div>
97 + <div id="create-repo-status" style="margin-top: 0.25rem;"></div>
98 + </div>
99 + <script>
100 + (function() {
101 + var btn = document.getElementById('create-repo-btn');
102 + var input = document.getElementById('create-repo-name');
103 + var status = document.getElementById('create-repo-status');
104 + if (!btn || !input) return;
105 + btn.addEventListener('click', function() {
106 + var name = input.value.trim();
107 + if (!name) return;
108 + btn.disabled = true;
109 + fetch('/api/repos', {
110 + method: 'POST',
111 + headers: {'Content-Type': 'application/json'},
112 + body: JSON.stringify({name: name})
113 + }).then(function(r) {
114 + if (r.ok) {
115 + htmx.ajax('GET', '/dashboard/project/{{ project.slug }}/tab/settings', {target: '#tab-content', swap: 'innerHTML'});
116 + } else {
117 + return r.json().then(function(data) {
118 + status.textContent = data.error || 'Failed to create repository';
119 + status.style.color = 'var(--error-color)';
120 + });
121 + }
122 + }).catch(function() {
123 + status.textContent = 'Network error';
124 + status.style.color = 'var(--error-color)';
125 + }).finally(function() {
126 + btn.disabled = false;
127 + });
128 + });
129 + })();
130 + </script>
89 131 </div>
90 132 {% endif %}
91 133
@@ -4,7 +4,7 @@
4 4
5 5 use crate::harness::TestHarness;
6 6
7 - /// Create a temp bare repo at `{dir}/owner/testrepo.git` with one commit on "main".
7 + /// Create a temp bare repo at `{dir}/testowner/testrepo.git` with one commit on "main".
8 8 /// Contains: README.md, src/main.rs
9 9 fn make_test_repo(dir: &std::path::Path) {
10 10 let bare_path = dir.join("testowner").join("testrepo.git");
@@ -44,6 +44,14 @@ fn make_test_repo(dir: &std::path::Path) {
44 44 bare_repo.set_head("refs/heads/main").unwrap();
45 45 }
46 46
47 + /// Set up a harness with git repos and a user matching the disk owner.
48 + async fn setup_git_harness(tmp: &tempfile::TempDir) -> TestHarness {
49 + let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await;
50 + // Create a user whose username matches the disk directory
51 + h.signup("testowner", "testowner@example.com", "password123").await;
52 + h
53 + }
54 +
47 55 // ── 404 when git not configured ──
48 56
49 57 #[tokio::test]
@@ -60,7 +68,7 @@ async fn git_repo_returns_404_when_not_configured() {
60 68 async fn git_repo_overview() {
61 69 let tmp = tempfile::TempDir::new().unwrap();
62 70 make_test_repo(tmp.path());
63 - let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await;
71 + let mut h = setup_git_harness(&tmp).await;
64 72
65 73 let resp = h.client.get("/git/testowner/testrepo").await;
66 74 assert!(
@@ -79,7 +87,7 @@ async fn git_repo_overview() {
79 87 async fn git_nonexistent_repo_returns_404() {
80 88 let tmp = tempfile::TempDir::new().unwrap();
81 89 make_test_repo(tmp.path());
82 - let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await;
90 + let mut h = setup_git_harness(&tmp).await;
83 91
84 92 let resp = h.client.get("/git/testowner/nope").await;
85 93 assert_eq!(resp.status, 404);
@@ -91,7 +99,7 @@ async fn git_nonexistent_repo_returns_404() {
91 99 async fn git_tree_at_ref() {
92 100 let tmp = tempfile::TempDir::new().unwrap();
93 101 make_test_repo(tmp.path());
94 - let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await;
102 + let mut h = setup_git_harness(&tmp).await;
95 103
96 104 let resp = h.client.get("/git/testowner/testrepo/tree/main").await;
97 105 assert!(
@@ -110,7 +118,7 @@ async fn git_tree_at_ref() {
110 118 async fn git_tree_subdirectory() {
111 119 let tmp = tempfile::TempDir::new().unwrap();
112 120 make_test_repo(tmp.path());
113 - let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await;
121 + let mut h = setup_git_harness(&tmp).await;
114 122
115 123 let resp = h
116 124 .client
@@ -130,7 +138,7 @@ async fn git_tree_subdirectory() {
130 138 async fn git_file_view() {
131 139 let tmp = tempfile::TempDir::new().unwrap();
132 140 make_test_repo(tmp.path());
133 - let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await;
141 + let mut h = setup_git_harness(&tmp).await;
134 142
135 143 let resp = h
136 144 .client
@@ -151,7 +159,7 @@ async fn git_file_view() {
151 159 async fn git_file_nonexistent_returns_404() {
152 160 let tmp = tempfile::TempDir::new().unwrap();
153 161 make_test_repo(tmp.path());
154 - let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await;
162 + let mut h = setup_git_harness(&tmp).await;
155 163
156 164 let resp = h
157 165 .client
@@ -166,7 +174,7 @@ async fn git_file_nonexistent_returns_404() {
166 174 async fn git_commit_log() {
167 175 let tmp = tempfile::TempDir::new().unwrap();
168 176 make_test_repo(tmp.path());
169 - let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await;
177 + let mut h = setup_git_harness(&tmp).await;
170 178
171 179 let resp = h
172 180 .client
@@ -189,7 +197,7 @@ async fn git_commit_log() {
189 197 async fn git_raw_file() {
190 198 let tmp = tempfile::TempDir::new().unwrap();
191 199 make_test_repo(tmp.path());
192 - let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await;
200 + let mut h = setup_git_harness(&tmp).await;
193 201
194 202 let resp = h
195 203 .client
@@ -212,7 +220,7 @@ async fn git_raw_file() {
212 220 async fn git_path_traversal_rejected() {
213 221 let tmp = tempfile::TempDir::new().unwrap();
214 222 make_test_repo(tmp.path());
215 - let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await;
223 + let mut h = setup_git_harness(&tmp).await;
216 224
217 225 let resp = h.client.get("/git/../etc/testrepo").await;
218 226 // Axum may normalize or reject; we just check it doesn't succeed
@@ -229,7 +237,7 @@ async fn git_path_traversal_rejected() {
229 237 async fn git_invalid_ref_returns_404() {
230 238 let tmp = tempfile::TempDir::new().unwrap();
231 239 make_test_repo(tmp.path());
232 - let mut h = TestHarness::with_git_repos(tmp.path().to_str().unwrap().to_string()).await;
240 + let mut h = setup_git_harness(&tmp).await;
233 241
234 242 let resp = h
235 243 .client
@@ -237,3 +245,51 @@ async fn git_invalid_ref_returns_404() {
237 245 .await;
238 246 assert_eq!(resp.status, 404);
239 247 }
248 +
249 + // ── Visibility: private repo ──
250 +
251 + #[tokio::test]
252 + async fn git_private_repo_hidden_from_anonymous() {
253 + let tmp = tempfile::TempDir::new().unwrap();
254 + make_test_repo(tmp.path());
255 + let mut h = setup_git_harness(&tmp).await;
256 +
257 + // Visit the repo to auto-register it
258 + let resp = h.client.get("/git/testowner/testrepo").await;
259 + assert!(resp.status.is_success());
260 +
261 + // Set visibility to private via SQL
262 + sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'")
263 + .execute(&h.db)
264 + .await
265 + .unwrap();
266 +
267 + // Log out so we're anonymous
268 + h.client.post_form("/logout", "").await;
269 +
270 + let resp = h.client.get("/git/testowner/testrepo").await;
271 + assert_eq!(resp.status, 404, "Private repo should be 404 for anonymous users");
272 + }
273 +
274 + #[tokio::test]
275 + async fn git_private_repo_visible_to_owner() {
276 + let tmp = tempfile::TempDir::new().unwrap();
277 + make_test_repo(tmp.path());
278 + let mut h = setup_git_harness(&tmp).await;
279 +
280 + // Visit the repo to auto-register it
281 + let resp = h.client.get("/git/testowner/testrepo").await;
282 + assert!(resp.status.is_success());
283 +
284 + // Set visibility to private
285 + sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'")
286 + .execute(&h.db)
287 + .await
288 + .unwrap();
289 +
290 + // Log in as the owner
291 + h.login("testowner", "password123").await;
292 +
293 + let resp = h.client.get("/git/testowner/testrepo").await;
294 + assert!(resp.status.is_success(), "Owner should see private repo: {} {}", resp.status, resp.text);
295 + }