fabrica

hanna/fabrica

feat: GitLab-style repo visibility (public / internal / private)

65b6581 · hanna committed on 2026-07-25

Replace the two-state is_private flag with a three-state Visibility across
the stack: model enum, migration 0004 (backfilled from is_private), store
column and set_visibility, and auth::access (public = anyone incl. anon when
allowed; internal = any signed-in user; private = owner/collaborators). The
exhaustive access matrix test now enumerates all three visibilities. Add
instance.default_visibility to config, a `repo visibility` CLI command with
--private/--internal/--public, and surface the label in listings and badges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
16 files changed · +358 −121UnifiedSplit
crates/api/src/handlers.rs +7 −2
@@ -143,7 +143,11 @@ pub async fn create_repo(
143 name: leaf,143 name: leaf,
144 path: body.name.clone(),144 path: body.name.clone(),
145 description: body.description,145 description: body.description,
146 is_private: body.private.unwrap_or(true),146 visibility: if body.private.unwrap_or(true) {
147 model::Visibility::Private
148 } else {
149 model::Visibility::Public
150 },
147 default_branch: branch.clone(),151 default_branch: branch.clone(),
148 })152 })
149 .await?;153 .await?;
@@ -483,7 +487,8 @@ fn repo_json(repo: &Repo, owner: &str) -> Value {
483 "owner": owner,487 "owner": owner,
484 "name": repo.name,488 "name": repo.name,
485 "path": repo.path,489 "path": repo.path,
486 "private": repo.is_private,490 "private": repo.visibility.is_private(),
491 "visibility": repo.visibility.as_str(),
487 "default_branch": repo.default_branch,492 "default_branch": repo.default_branch,
488 "description": repo.description,493 "description": repo.description,
489 "archived": repo.archived_at.is_some(),494 "archived": repo.archived_at.is_some(),
crates/auth/src/access.rs +69 −57
@@ -12,7 +12,7 @@
12//! [`Access::None`] on a private repo renders `404`, never `403`, so existence12//! [`Access::None`] on a private repo renders `404`, never `403`, so existence
13//! does not leak — belong to the web and API layers.13//! does not leak — belong to the web and API layers.
1414
15use model::Repo;15use model::{Repo, Visibility};
1616
17/// The access level a viewer has to a repository, in increasing order of power.17/// The access level a viewer has to a repository, in increasing order of power.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
@@ -95,15 +95,15 @@ pub struct Viewer {
95/// 1. a site admin gets [`Access::Admin`];95/// 1. a site admin gets [`Access::Admin`];
96/// 2. the repo's owner gets [`Access::Admin`];96/// 2. the repo's owner gets [`Access::Admin`];
97/// 3. an explicit collaborator gets exactly their stored permission;97/// 3. an explicit collaborator gets exactly their stored permission;
98/// 4. a public repo, when the instance allows anonymous access, grants98/// 4. otherwise the repo's [`Visibility`] decides:
99/// [`Access::Read`] to anyone;99/// - **public**: [`Access::Read`] for any signed-in viewer, and for anonymous
100/// 5. otherwise [`Access::None`].100/// viewers when the instance allows anonymous access;
101/// - **internal**: [`Access::Read`] for any signed-in viewer, never anonymous;
102/// - **private**: [`Access::None`].
101///103///
102/// `collaborator` is the viewer's row in `repo_collaborators` for this repo, if104/// `collaborator` is the viewer's row in `repo_collaborators` for this repo, if
103/// any; the caller looks it up. `allow_anonymous` is `instance.allow_anonymous`.105/// any; the caller looks it up. `allow_anonymous` is `instance.allow_anonymous`
104/// Note rule 4 is gated on that flag for *every* viewer, so disabling anonymous106/// and now gates only anonymous access to *public* repos.
105/// access closes public repos to non-collaborators as well — matching the spec's
106/// private-by-default posture.
107#[must_use]107#[must_use]
108pub fn access(108pub fn access(
109 viewer: Option<&Viewer>,109 viewer: Option<&Viewer>,
@@ -122,10 +122,11 @@ pub fn access(
122 return permission.into();122 return permission.into();
123 }123 }
124 }124 }
125 if !repo.is_private && allow_anonymous {125 match repo.visibility {
126 return Access::Read;126 Visibility::Public if viewer.is_some() || allow_anonymous => Access::Read,
127 Visibility::Internal if viewer.is_some() => Access::Read,
128 _ => Access::None,
127 }129 }
128 Access::None
129}130}
130131
131#[cfg(test)]132#[cfg(test)]
@@ -136,7 +137,7 @@ mod tests {
136137
137 /// A repo owned by `owner`, with the given visibility. Only the fields138 /// A repo owned by `owner`, with the given visibility. Only the fields
138 /// `access` reads are meaningful; the rest are filler.139 /// `access` reads are meaningful; the rest are filler.
139 fn repo(owner: &str, is_private: bool) -> Repo {140 fn repo(owner: &str, visibility: Visibility) -> Repo {
140 Repo {141 Repo {
141 id: "01reporeporeporeporeporepo".to_string(),142 id: "01reporeporeporeporeporepo".to_string(),
142 owner_id: owner.to_string(),143 owner_id: owner.to_string(),
@@ -144,7 +145,7 @@ mod tests {
144 name: "r".to_string(),145 name: "r".to_string(),
145 path: "r".to_string(),146 path: "r".to_string(),
146 description: None,147 description: None,
147 is_private,148 visibility,
148 default_branch: "main".to_string(),149 default_branch: "main".to_string(),
149 archived_at: None,150 archived_at: None,
150 size_bytes: 0,151 size_bytes: 0,
@@ -154,6 +155,13 @@ mod tests {
154 }155 }
155 }156 }
156157
158 /// Every visibility, for exhaustive iteration.
159 const ALL_VIS: [Visibility; 3] = [
160 Visibility::Public,
161 Visibility::Internal,
162 Visibility::Private,
163 ];
164
157 fn viewer(id: &str, is_admin: bool) -> Viewer {165 fn viewer(id: &str, is_admin: bool) -> Viewer {
158 Viewer {166 Viewer {
159 id: id.to_string(),167 id: id.to_string(),
@@ -180,7 +188,7 @@ mod tests {
180 struct Case {188 struct Case {
181 viewer: Option<Viewer>,189 viewer: Option<Viewer>,
182 collaborator: Option<Permission>,190 collaborator: Option<Permission>,
183 is_private: bool,191 visibility: Visibility,
184 allow_anonymous: bool,192 allow_anonymous: bool,
185 expect: Access,193 expect: Access,
186 }194 }
@@ -198,82 +206,73 @@ mod tests {
198 let mut cases = Vec::new();206 let mut cases = Vec::new();
199 let mut push = |viewer: Option<&Viewer>,207 let mut push = |viewer: Option<&Viewer>,
200 collaborator: Option<Permission>,208 collaborator: Option<Permission>,
201 is_private: bool,209 visibility: Visibility,
202 allow_anonymous: bool,210 allow_anonymous: bool,
203 expect: Access| {211 expect: Access| {
204 cases.push(Case {212 cases.push(Case {
205 viewer: viewer.cloned(),213 viewer: viewer.cloned(),
206 collaborator,214 collaborator,
207 is_private,215 visibility,
208 allow_anonymous,216 allow_anonymous,
209 expect,217 expect,
210 });218 });
211 };219 };
212220
213 // Rule 1: a site admin is Admin everywhere, regardless of every other221 for &vis in &ALL_VIS {
214 // dimension.
215 for &private in &[true, false] {
216 for &anon in &[true, false] {222 for &anon in &[true, false] {
223 // Rule 1: a site admin is Admin everywhere, regardless of every
224 // other dimension.
217 for collab in [None, Some(Permission::Read)] {225 for collab in [None, Some(Permission::Read)] {
218 push(Some(&admin), collab, private, anon, Access::Admin);226 push(Some(&admin), collab, vis, anon, Access::Admin);
219 }227 }
220 }228 // Rule 2: the owner is Admin everywhere, even with a lesser
221 }229 // collaborator row present and anonymous access off.
222
223 // Rule 2: the owner is Admin everywhere, even with a lesser collaborator
224 // row somehow present, and even when anonymous access is off.
225 for &private in &[true, false] {
226 for &anon in &[true, false] {
227 for collab in [None, Some(Permission::Read), Some(Permission::Write)] {230 for collab in [None, Some(Permission::Read), Some(Permission::Write)] {
228 push(Some(&owner), collab, private, anon, Access::Admin);231 push(Some(&owner), collab, vis, anon, Access::Admin);
229 }232 }
230 }233 // Rule 3: an explicit collaborator gets exactly their permission,
231 }234 // beating visibility and the anonymous flag.
232
233 // Rule 3: an explicit collaborator gets exactly their permission — this
234 // beats rule 4, so it holds even on a public repo, and is unaffected by
235 // the anonymous flag or visibility.
236 for &private in &[true, false] {
237 for &anon in &[true, false] {
238 push(235 push(
239 Some(&other),236 Some(&other),
240 Some(Permission::Read),237 Some(Permission::Read),
241 private,238 vis,
242 anon,239 anon,
243 Access::Read,240 Access::Read,
244 );241 );
245 push(242 push(
246 Some(&other),243 Some(&other),
247 Some(Permission::Write),244 Some(Permission::Write),
248 private,245 vis,
249 anon,246 anon,
250 Access::Write,247 Access::Write,
251 );248 );
252 push(249 push(
253 Some(&other),250 Some(&other),
254 Some(Permission::Admin),251 Some(Permission::Admin),
255 private,252 vis,
256 anon,253 anon,
257 Access::Admin,254 Access::Admin,
258 );255 );
259 }256 }
260 }257 }
261258
262 // Rule 4 / 5: a non-collaborator (authenticated stranger or anonymous)259 // Rule 4: a non-collaborator, by visibility.
263 // reads a public repo only when anonymous access is allowed; a private260 // Signed-in stranger: reads public and internal always; never private.
264 // repo is always None; and anon-off closes public repos too.261 for &anon in &[true, false] {
265 for viewer in [Some(&other), None] {262 push(Some(&other), None, Visibility::Public, anon, Access::Read);
266 // Public repo, anonymous allowed -> Read.263 push(Some(&other), None, Visibility::Internal, anon, Access::Read);
267 push(viewer, None, false, true, Access::Read);264 push(Some(&other), None, Visibility::Private, anon, Access::None);
268 // Public repo, anonymous disallowed -> None.
269 push(viewer, None, false, false, Access::None);
270 // Private repo -> None regardless of the flag.
271 push(viewer, None, true, true, Access::None);
272 push(viewer, None, true, false, Access::None);
273 }265 }
266 // Anonymous: reads public only when allowed; never internal or private.
267 push(None, None, Visibility::Public, true, Access::Read);
268 push(None, None, Visibility::Public, false, Access::None);
269 push(None, None, Visibility::Internal, true, Access::None);
270 push(None, None, Visibility::Internal, false, Access::None);
271 push(None, None, Visibility::Private, true, Access::None);
272 push(None, None, Visibility::Private, false, Access::None);
274273
275 for case in &cases {274 for case in &cases {
276 let repo = repo("owner", case.is_private);275 let repo = repo("owner", case.visibility);
277 let got = access(276 let got = access(
278 case.viewer.as_ref(),277 case.viewer.as_ref(),
279 &repo,278 &repo,
@@ -282,23 +281,36 @@ mod tests {
282 );281 );
283 assert_eq!(282 assert_eq!(
284 got, case.expect,283 got, case.expect,
285 "viewer={:?} collab={:?} private={} anon={}",284 "viewer={:?} collab={:?} vis={} anon={}",
286 case.viewer, case.collaborator, case.is_private, case.allow_anonymous285 case.viewer, case.collaborator, case.visibility, case.allow_anonymous
287 );286 );
288 }287 }
289 }288 }
290289
291 #[test]290 #[test]
292 fn private_repo_never_leaks_to_a_stranger() {291 fn private_repo_never_leaks_to_a_stranger() {
293 // The security-critical invariant, called out on its own: no combination292 // The security-critical invariant: no combination of a non-privileged
294 // of a non-privileged stranger on a private repo yields anything but None.293 // stranger on a private repo yields anything but None. Internal repos also
294 // never leak to anonymous visitors.
295 let stranger = viewer("stranger", false);295 let stranger = viewer("stranger", false);
296 for &anon in &[true, false] {296 for &anon in &[true, false] {
297 assert_eq!(297 assert_eq!(
298 access(Some(&stranger), &repo("owner", true), None, anon),298 access(
299 Some(&stranger),
300 &repo("owner", Visibility::Private),
301 None,
302 anon
303 ),
304 Access::None
305 );
306 assert_eq!(
307 access(None, &repo("owner", Visibility::Private), None, anon),
308 Access::None
309 );
310 assert_eq!(
311 access(None, &repo("owner", Visibility::Internal), None, anon),
299 Access::None312 Access::None
300 );313 );
301 assert_eq!(access(None, &repo("owner", true), None, anon), Access::None);
302 }314 }
303 }315 }
304}316}
crates/cli/src/repo_cmd.rs +108 −22
@@ -25,10 +25,13 @@ pub(crate) enum RepoCommand {
25 user: String,25 user: String,
26 /// The repo name, optionally prefixed with a group path.26 /// The repo name, optionally prefixed with a group path.
27 name: String,27 name: String,
28 /// Make the repo private (the default).28 /// Make the repo private (owner and collaborators only).
29 #[arg(long, conflicts_with = "public")]29 #[arg(long, conflicts_with_all = ["public", "internal"])]
30 private: bool,30 private: bool,
31 /// Make the repo public.31 /// Make the repo internal (any signed-in user).
32 #[arg(long, conflicts_with = "public")]
33 internal: bool,
34 /// Make the repo public (everyone).
32 #[arg(long)]35 #[arg(long)]
33 public: bool,36 public: bool,
34 /// A one-line description.37 /// A one-line description.
@@ -66,6 +69,22 @@ pub(crate) enum RepoCommand {
66 /// The new leaf name (the group path is preserved).69 /// The new leaf name (the group path is preserved).
67 new_name: String,70 new_name: String,
68 },71 },
72 /// Change a repository's visibility (private / internal / public).
73 Visibility {
74 /// The owning user.
75 user: String,
76 /// The repo name (group path included).
77 name: String,
78 /// Make the repo private (owner and collaborators only).
79 #[arg(long, conflicts_with_all = ["public", "internal"])]
80 private: bool,
81 /// Make the repo internal (any signed-in user).
82 #[arg(long, conflicts_with = "public")]
83 internal: bool,
84 /// Make the repo public (everyone).
85 #[arg(long)]
86 public: bool,
87 },
69 /// Print the resolved on-disk directory of a repository.88 /// Print the resolved on-disk directory of a repository.
70 Path {89 Path {
71 /// The owning user.90 /// The owning user.
@@ -85,7 +104,8 @@ pub(crate) fn run(
85 RepoCommand::Add {104 RepoCommand::Add {
86 user,105 user,
87 name,106 name,
88 private: _private,107 private,
108 internal,
89 public,109 public,
90 description,110 description,
91 default_branch,111 default_branch,
@@ -93,9 +113,7 @@ pub(crate) fn run(
93 config_path,113 config_path,
94 user,114 user,
95 name,115 name,
96 // Private is the default; `--private` is the explicit form of it, and116 (*private, *internal, *public),
97 // conflicts with `--public`, so visibility is simply "not public".
98 !*public,
99 description.clone(),117 description.clone(),
100 default_branch.clone(),118 default_branch.clone(),
101 )),119 )),
@@ -111,6 +129,18 @@ pub(crate) fn run(
111 name,129 name,
112 new_name,130 new_name,
113 } => block_on(rename(config_path, user, name, new_name)),131 } => block_on(rename(config_path, user, name, new_name)),
132 RepoCommand::Visibility {
133 user,
134 name,
135 private,
136 internal,
137 public,
138 } => block_on(visibility(
139 config_path,
140 user,
141 name,
142 (*private, *internal, *public),
143 )),
114 RepoCommand::Path { user, name } => block_on(path(config_path, user, name)),144 RepoCommand::Path { user, name } => block_on(path(config_path, user, name)),
115 }145 }
116}146}
@@ -145,11 +175,30 @@ fn split_group_path(name: &str) -> (Option<&str>, &str) {
145 }175 }
146}176}
147177
178/// Resolve the visibility from the `(private, internal, public)` flags, falling
179/// back to the instance default when none is given. clap guarantees at most one
180/// flag is set.
181fn resolve_visibility(
182 flags: (bool, bool, bool),
183 default: config::DefaultVisibility,
184) -> model::Visibility {
185 let (private, internal, public) = flags;
186 if private {
187 model::Visibility::Private
188 } else if internal {
189 model::Visibility::Internal
190 } else if public {
191 model::Visibility::Public
192 } else {
193 model::Visibility::from_token(default.as_token()).unwrap_or(model::Visibility::Private)
194 }
195}
196
148async fn add(197async fn add(
149 config_path: Option<&Path>,198 config_path: Option<&Path>,
150 user: &str,199 user: &str,
151 name: &str,200 name: &str,
152 is_private: bool,201 flags: (bool, bool, bool),
153 description: Option<String>,202 description: Option<String>,
154 default_branch: Option<String>,203 default_branch: Option<String>,
155) -> anyhow::Result<ExitCode> {204) -> anyhow::Result<ExitCode> {
@@ -162,6 +211,7 @@ async fn add(
162 None => None,211 None => None,
163 };212 };
164 let branch = default_branch.unwrap_or_else(|| config.instance.default_branch.clone());213 let branch = default_branch.unwrap_or_else(|| config.instance.default_branch.clone());
214 let visibility = resolve_visibility(flags, config.instance.default_visibility);
165215
166 let repo = store216 let repo = store
167 .create_repo(NewRepo {217 .create_repo(NewRepo {
@@ -170,7 +220,7 @@ async fn add(
170 name: split_group_path(name).1.to_string(),220 name: split_group_path(name).1.to_string(),
171 path: name.to_string(),221 path: name.to_string(),
172 description,222 description,
173 is_private,223 visibility,
174 default_branch: branch.clone(),224 default_branch: branch.clone(),
175 })225 })
176 .await?;226 .await?;
@@ -183,11 +233,7 @@ async fn add(
183 return Err(err.into());233 return Err(err.into());
184 }234 }
185235
186 println!(236 println!("created {visibility} repository {user}/{}", repo.path);
187 "created {} repository {user}/{}",
188 if is_private { "private" } else { "public" },
189 repo.path
190 );
191 Ok(ExitCode::SUCCESS)237 Ok(ExitCode::SUCCESS)
192}238}
193239
@@ -288,9 +334,7 @@ async fn list(
288 for repo in &repos {334 for repo in &repos {
289 println!(335 println!(
290 "{}/{} [{}]",336 "{}/{} [{}]",
291 owners[&repo.owner_id],337 owners[&repo.owner_id], repo.path, repo.visibility
292 repo.path,
293 if repo.is_private { "private" } else { "public" }
294 );338 );
295 }339 }
296 Ok(ExitCode::SUCCESS)340 Ok(ExitCode::SUCCESS)
@@ -316,6 +360,25 @@ async fn rename(
316 Ok(ExitCode::SUCCESS)360 Ok(ExitCode::SUCCESS)
317}361}
318362
363async fn visibility(
364 config_path: Option<&Path>,
365 user: &str,
366 name: &str,
367 flags: (bool, bool, bool),
368) -> anyhow::Result<ExitCode> {
369 if flags == (false, false, false) {
370 anyhow::bail!("specify --private, --internal, or --public");
371 }
372 let (_config, store) = open_store(config_path).await?;
373 // A concrete flag is set, so the default is never consulted here.
374 let visibility = resolve_visibility(flags, config::DefaultVisibility::Private);
375 let owner_id = require_user_id(&store, user).await?;
376 let repo = require_repo(&store, &owner_id, name, user).await?;
377 store.set_visibility(&repo.id, visibility).await?;
378 println!("{user}/{} is now {visibility}", repo.path);
379 Ok(ExitCode::SUCCESS)
380}
381
319async fn path(config_path: Option<&Path>, user: &str, name: &str) -> anyhow::Result<ExitCode> {382async fn path(config_path: Option<&Path>, user: &str, name: &str) -> anyhow::Result<ExitCode> {
320 let (config, store) = open_store(config_path).await?;383 let (config, store) = open_store(config_path).await?;
321 let owner_id = require_user_id(&store, user).await?;384 let owner_id = require_user_id(&store, user).await?;
@@ -342,7 +405,7 @@ fn repo_json(repo: &Repo, owner: &str) -> serde_json::Value {
342 "name": repo.name,405 "name": repo.name,
343 "path": repo.path,406 "path": repo.path,
344 "description": repo.description,407 "description": repo.description,
345 "private": repo.is_private,408 "visibility": repo.visibility.as_str(),
346 "default_branch": repo.default_branch,409 "default_branch": repo.default_branch,
347 "created_at": repo.created_at,410 "created_at": repo.created_at,
348 })411 })
@@ -361,7 +424,15 @@ mod tests {
361 block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap();424 block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap();
362425
363 // A grouped repo auto-creates the intermediate group and a bare repo dir.426 // A grouped repo auto-creates the intermediate group and a bare repo dir.
364 block_on(add(env.path(), "ada", "backend/svc", true, None, None)).unwrap();427 block_on(add(
428 env.path(),
429 "ada",
430 "backend/svc",
431 (true, false, false),
432 None,
433 None,
434 ))
435 .unwrap();
365436
366 let (repo, dir) = block_on(async {437 let (repo, dir) = block_on(async {
367 let store = env.store().await;438 let store = env.store().await;
@@ -375,7 +446,7 @@ mod tests {
375 })446 })
376 .unwrap();447 .unwrap();
377 assert_eq!(repo.path, "backend/svc");448 assert_eq!(repo.path, "backend/svc");
378 assert!(repo.is_private);449 assert_eq!(repo.visibility, model::Visibility::Private);
379 assert!(dir.join("HEAD").exists(), "a bare repo was created on disk");450 assert!(dir.join("HEAD").exists(), "a bare repo was created on disk");
380451
381 // Rename preserves the group prefix.452 // Rename preserves the group prefix.
@@ -404,10 +475,25 @@ mod tests {
404 fn add_rolls_back_the_row_if_the_disk_repo_cannot_be_created() {475 fn add_rolls_back_the_row_if_the_disk_repo_cannot_be_created() {
405 let env = env();476 let env = env();
406 block_on(async { anyhow::Ok(env.make_user("eve").await) }).unwrap();477 block_on(async { anyhow::Ok(env.make_user("eve").await) }).unwrap();
407 block_on(add(env.path(), "eve", "proj", true, None, None)).unwrap();478 block_on(add(
479 env.path(),
480 "eve",
481 "proj",
482 (true, false, false),
483 None,
484 None,
485 ))
486 .unwrap();
408 // A second create at the same id path is impossible, but a duplicate487 // A second create at the same id path is impossible, but a duplicate
409 // logical repo must conflict at the store layer and leave nothing behind.488 // logical repo must conflict at the store layer and leave nothing behind.
410 let dup = block_on(add(env.path(), "eve", "proj", true, None, None));489 let dup = block_on(add(
490 env.path(),
491 "eve",
492 "proj",
493 (true, false, false),
494 None,
495 None,
496 ));
411 assert!(dup.is_err());497 assert!(dup.is_err());
412 }498 }
413}499}
crates/cli/src/user_cmd.rs +1 −1
@@ -521,7 +521,7 @@ mod tests {
521 name: "proj".to_string(),521 name: "proj".to_string(),
522 path: "proj".to_string(),522 path: "proj".to_string(),
523 description: None,523 description: None,
524 is_private: true,524 visibility: model::Visibility::Private,
525 default_branch: "main".to_string(),525 default_branch: "main".to_string(),
526 })526 })
527 .await?;527 .await?;
crates/config/src/lib.rs +2 −2
@@ -32,8 +32,8 @@ use figment::providers::{Env, Format, Serialized, Toml};
3232
33pub use crate::secret::{REDACTED, Secret};33pub use crate::secret::{REDACTED, Secret};
34pub use crate::types::{34pub use crate::types::{
35 Auth, Config, Database, Git, Instance, Log, LogFormat, LogLevel, Mail, MailBackend,35 Auth, Config, Database, DefaultVisibility, Git, Instance, Log, LogFormat, LogLevel, Mail,
36 MailEncryption, Search, Server, Ssh, Storage, Ui,36 MailBackend, MailEncryption, Search, Server, Ssh, Storage, Ui,
37};37};
3838
39/// System-wide configuration path, the lowest-priority file source.39/// System-wide configuration path, the lowest-priority file source.
crates/config/src/types.rs +29 −0
@@ -64,6 +64,31 @@ impl Default for Search {
64 }64 }
65}65}
6666
67/// Default visibility for newly created repositories, GitLab-style.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum DefaultVisibility {
71 /// Visible to everyone (anonymous included, when allowed).
72 Public,
73 /// Visible to any signed-in user.
74 Internal,
75 /// Visible only to the owner and collaborators.
76 #[default]
77 Private,
78}
79
80impl DefaultVisibility {
81 /// The lowercase token, matching `model::Visibility::from_token`.
82 #[must_use]
83 pub fn as_token(self) -> &'static str {
84 match self {
85 Self::Public => "public",
86 Self::Internal => "internal",
87 Self::Private => "private",
88 }
89 }
90}
91
67/// `[instance]` — identity and global policy.92/// `[instance]` — identity and global policy.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]94#[serde(deny_unknown_fields)]
@@ -78,6 +103,9 @@ pub struct Instance {
78 pub default_branch: String,103 pub default_branch: String,
79 /// Allow anonymous browse and clone of public repositories.104 /// Allow anonymous browse and clone of public repositories.
80 pub allow_anonymous: bool,105 pub allow_anonymous: bool,
106 /// Default visibility for newly created repositories.
107 #[serde(default)]
108 pub default_visibility: DefaultVisibility,
81}109}
82110
83impl Default for Instance {111impl Default for Instance {
@@ -88,6 +116,7 @@ impl Default for Instance {
88 description: String::new(),116 description: String::new(),
89 default_branch: "main".to_string(),117 default_branch: "main".to_string(),
90 allow_anonymous: true,118 allow_anonymous: true,
119 default_visibility: DefaultVisibility::Private,
91 }120 }
92 }121 }
93}122}
crates/model/src/entity.rs +50 −2
@@ -54,6 +54,54 @@ impl std::fmt::Display for KeyKind {
54 }54 }
55}55}
5656
57/// A repository's visibility, GitLab-style. Stored as the `repos.visibility`
58/// text token.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum Visibility {
61 /// Visible to everyone, anonymous included (subject to `allow_anonymous`).
62 Public,
63 /// Visible to any signed-in user, but not to anonymous visitors.
64 Internal,
65 /// Visible only to the owner, collaborators, and site admins.
66 #[default]
67 Private,
68}
69
70impl Visibility {
71 /// The lowercase database/wire token.
72 #[must_use]
73 pub fn as_str(self) -> &'static str {
74 match self {
75 Self::Public => "public",
76 Self::Internal => "internal",
77 Self::Private => "private",
78 }
79 }
80
81 /// Parse from the token, case-insensitively.
82 #[must_use]
83 pub fn from_token(s: &str) -> Option<Self> {
84 match s.to_ascii_lowercase().as_str() {
85 "public" => Some(Self::Public),
86 "internal" => Some(Self::Internal),
87 "private" => Some(Self::Private),
88 _ => None,
89 }
90 }
91
92 /// Whether the repository is private (only owner/collaborators/admins).
93 #[must_use]
94 pub fn is_private(self) -> bool {
95 matches!(self, Self::Private)
96 }
97}
98
99impl std::fmt::Display for Visibility {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 f.write_str(self.as_str())
102 }
103}
104
57/// A registered account.105/// A registered account.
58#[derive(Debug, Clone, PartialEq, Eq)]106#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct User {107pub struct User {
@@ -195,8 +243,8 @@ pub struct Repo {
195 pub path: String,243 pub path: String,
196 /// Optional one-line description.244 /// Optional one-line description.
197 pub description: Option<String>,245 pub description: Option<String>,
198 /// Private repositories are invisible to non-collaborators.246 /// Who may see the repository (public / internal / private).
199 pub is_private: bool,247 pub visibility: Visibility,
200 /// The branch shown by default and used for HEAD.248 /// The branch shown by default and used for HEAD.
201 pub default_branch: String,249 pub default_branch: String,
202 /// When the repo was archived (read-only), if it is.250 /// When the repo was archived (read-only), if it is.
crates/model/src/lib.rs +1 −1
@@ -11,5 +11,5 @@
11pub mod entity;11pub mod entity;
12pub mod name;12pub mod name;
1313
14pub use entity::{ApiToken, Group, Invite, Key, KeyKind, Repo, Timestamp, User};14pub use entity::{ApiToken, Group, Invite, Key, KeyKind, Repo, Timestamp, User, Visibility};
15pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};15pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};
crates/store/src/repos.rs +34 −8
@@ -4,14 +4,14 @@
44
5//! Repositories: creation and path lookup.5//! Repositories: creation and path lookup.
66
7use model::Repo;7use model::{Repo, Visibility};
8use sqlx::any::AnyRow;8use sqlx::any::AnyRow;
9use sqlx::{AssertSqlSafe, Row};9use sqlx::{AssertSqlSafe, Row};
1010
11use crate::{Store, StoreError, map_conflict, new_id, now_ms};11use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1212
13/// The columns of `repos` in a fixed order, shared by every `SELECT`.13/// The columns of `repos` in a fixed order, shared by every `SELECT`.
14const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, is_private, \14const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \
15 default_branch, archived_at, size_bytes, pushed_at, created_at, updated_at";15 default_branch, archived_at, size_bytes, pushed_at, created_at, updated_at";
1616
17/// Fields needed to create a repository. The server fills in the id, timestamps,17/// Fields needed to create a repository. The server fills in the id, timestamps,
@@ -28,8 +28,8 @@ pub struct NewRepo {
28 pub path: String,28 pub path: String,
29 /// Optional one-line description.29 /// Optional one-line description.
30 pub description: Option<String>,30 pub description: Option<String>,
31 /// Whether the repo is private (invisible to non-collaborators).31 /// The repo's visibility (public / internal / private).
32 pub is_private: bool,32 pub visibility: Visibility,
33 /// The repo's default branch.33 /// The repo's default branch.
34 pub default_branch: String,34 pub default_branch: String,
35}35}
@@ -52,7 +52,7 @@ impl Store {
52 name: new.name,52 name: new.name,
53 path: new.path,53 path: new.path,
54 description: new.description,54 description: new.description,
55 is_private: new.is_private,55 visibility: new.visibility,
56 default_branch: new.default_branch,56 default_branch: new.default_branch,
57 archived_at: None,57 archived_at: None,
58 size_bytes: 0,58 size_bytes: 0,
@@ -62,7 +62,7 @@ impl Store {
62 };62 };
6363
64 let sql = "INSERT INTO repos \64 let sql = "INSERT INTO repos \
65 (id, owner_id, group_id, name, path, description, is_private, default_branch, \65 (id, owner_id, group_id, name, path, description, visibility, default_branch, \
66 archived_at, size_bytes, pushed_at, created_at, updated_at) \66 archived_at, size_bytes, pushed_at, created_at, updated_at) \
67 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)";67 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)";
68 sqlx::query(sql)68 sqlx::query(sql)
@@ -72,7 +72,7 @@ impl Store {
72 .bind(repo.name.clone())72 .bind(repo.name.clone())
73 .bind(repo.path.clone())73 .bind(repo.path.clone())
74 .bind(repo.description.clone())74 .bind(repo.description.clone())
75 .bind(repo.is_private)75 .bind(repo.visibility.as_str())
76 .bind(repo.default_branch.clone())76 .bind(repo.default_branch.clone())
77 .bind(repo.archived_at)77 .bind(repo.archived_at)
78 .bind(repo.size_bytes)78 .bind(repo.size_bytes)
@@ -182,6 +182,28 @@ impl Store {
182 Ok(updated > 0)182 Ok(updated > 0)
183 }183 }
184184
185 /// Set a repository's visibility (private vs public). Returns `true` if the
186 /// row existed and was updated.
187 ///
188 /// # Errors
189 ///
190 /// Returns [`StoreError::Query`] on a database failure.
191 pub async fn set_visibility(
192 &self,
193 repo_id: &str,
194 visibility: Visibility,
195 ) -> Result<bool, StoreError> {
196 let updated =
197 sqlx::query("UPDATE repos SET visibility = $1, updated_at = $2 WHERE id = $3")
198 .bind(visibility.as_str())
199 .bind(now_ms())
200 .bind(repo_id)
201 .execute(&self.pool)
202 .await?
203 .rows_affected();
204 Ok(updated > 0)
205 }
206
185 /// Record that a repository was just pushed to: stamp `pushed_at` and refresh207 /// Record that a repository was just pushed to: stamp `pushed_at` and refresh
186 /// `size_bytes`. Called by `fabrica hook post-receive`.208 /// `size_bytes`. Called by `fabrica hook post-receive`.
187 ///209 ///
@@ -240,6 +262,9 @@ impl Store {
240 }262 }
241263
242 /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`].264 /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`].
265 // A method (not an associated fn) for symmetry with the other row mappers,
266 // even though the visibility mapping no longer needs `self`.
267 #[allow(clippy::unused_self)]
243 fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {268 fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {
244 Ok(Repo {269 Ok(Repo {
245 id: row.try_get("id")?,270 id: row.try_get("id")?,
@@ -248,7 +273,8 @@ impl Store {
248 name: row.try_get("name")?,273 name: row.try_get("name")?,
249 path: row.try_get("path")?,274 path: row.try_get("path")?,
250 description: row.try_get("description")?,275 description: row.try_get("description")?,
251 is_private: self.get_bool(row, "is_private")?,276 visibility: Visibility::from_token(&row.try_get::<String, _>("visibility")?)
277 .unwrap_or(Visibility::Private),
252 default_branch: row.try_get("default_branch")?,278 default_branch: row.try_get("default_branch")?,
253 archived_at: row.try_get("archived_at")?,279 archived_at: row.try_get("archived_at")?,
254 size_bytes: row.try_get("size_bytes")?,280 size_bytes: row.try_get("size_bytes")?,
crates/store/src/tests.rs +9 −9
@@ -358,7 +358,7 @@ async fn delete_user_cascades_to_repos() {
358 name: "r".to_string(),358 name: "r".to_string(),
359 path: "r".to_string(),359 path: "r".to_string(),
360 description: None,360 description: None,
361 is_private: true,361 visibility: model::Visibility::Private,
362 default_branch: "main".to_string(),362 default_branch: "main".to_string(),
363 })363 })
364 .await364 .await
@@ -390,7 +390,7 @@ async fn repos_by_owner_lists_in_path_order() {
390 name: name.to_string(),390 name: name.to_string(),
391 path: name.to_string(),391 path: name.to_string(),
392 description: None,392 description: None,
393 is_private: true,393 visibility: model::Visibility::Private,
394 default_branch: "main".to_string(),394 default_branch: "main".to_string(),
395 })395 })
396 .await396 .await
@@ -425,14 +425,14 @@ async fn create_and_fetch_repo() {
425 name: "project".to_string(),425 name: "project".to_string(),
426 path: "project".to_string(),426 path: "project".to_string(),
427 description: Some("a repo".to_string()),427 description: Some("a repo".to_string()),
428 is_private: false,428 visibility: model::Visibility::Public,
429 default_branch: "main".to_string(),429 default_branch: "main".to_string(),
430 })430 })
431 .await431 .await
432 .unwrap();432 .unwrap();
433 assert_eq!(repo.size_bytes, 0);433 assert_eq!(repo.size_bytes, 0);
434 assert!(repo.pushed_at.is_none());434 assert!(repo.pushed_at.is_none());
435 assert!(!repo.is_private);435 assert_eq!(repo.visibility, model::Visibility::Public);
436436
437 let fetched = fx437 let fetched = fx
438 .store438 .store
@@ -451,7 +451,7 @@ async fn create_and_fetch_repo() {
451 name: "project".to_string(),451 name: "project".to_string(),
452 path: "project".to_string(),452 path: "project".to_string(),
453 description: None,453 description: None,
454 is_private: true,454 visibility: model::Visibility::Private,
455 default_branch: "main".to_string(),455 default_branch: "main".to_string(),
456 })456 })
457 .await457 .await
@@ -482,7 +482,7 @@ async fn foreign_keys_are_enforced() {
482 name: "orphan".to_string(),482 name: "orphan".to_string(),
483 path: "orphan".to_string(),483 path: "orphan".to_string(),
484 description: None,484 description: None,
485 is_private: true,485 visibility: model::Visibility::Private,
486 default_branch: "main".to_string(),486 default_branch: "main".to_string(),
487 })487 })
488 .await488 .await
@@ -767,7 +767,7 @@ async fn rename_and_delete_repo() {
767 name: "old".to_string(),767 name: "old".to_string(),
768 path: "old".to_string(),768 path: "old".to_string(),
769 description: None,769 description: None,
770 is_private: true,770 visibility: model::Visibility::Private,
771 default_branch: "main".to_string(),771 default_branch: "main".to_string(),
772 })772 })
773 .await773 .await
@@ -944,7 +944,7 @@ async fn postgres_round_trip() {
944 name: "pgproj".to_string(),944 name: "pgproj".to_string(),
945 path: "pgproj".to_string(),945 path: "pgproj".to_string(),
946 description: None,946 description: None,
947 is_private: false,947 visibility: model::Visibility::Public,
948 default_branch: "main".to_string(),948 default_branch: "main".to_string(),
949 })949 })
950 .await950 .await
@@ -955,7 +955,7 @@ async fn postgres_round_trip() {
955 .unwrap()955 .unwrap()
956 .unwrap();956 .unwrap();
957 assert_eq!(fetched_repo, repo);957 assert_eq!(fetched_repo, repo);
958 assert!(!fetched_repo.is_private);958 assert_eq!(fetched_repo.visibility, model::Visibility::Public);
959959
960 // Clean up (repo cascades with the user).960 // Clean up (repo cascades with the user).
961 sqlx::query("DELETE FROM users WHERE id = $1")961 sqlx::query("DELETE FROM users WHERE id = $1")
crates/web/src/pages.rs +4 −1
@@ -121,7 +121,10 @@ fn explore_subnav(active: &str) -> Markup {
121async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> {121async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> {
122 let needle = q.trim().to_lowercase();122 let needle = q.trim().to_lowercase();
123 let all = state.store.list_repos().await?;123 let all = state.store.list_repos().await?;
124 let public: Vec<_> = all.into_iter().filter(|r| !r.is_private).collect();124 let public: Vec<_> = all
125 .into_iter()
126 .filter(|r| r.visibility == model::Visibility::Public)
127 .collect();
125 // Resolve owner names once for the cross-owner labels/links and search.128 // Resolve owner names once for the cross-owner labels/links and search.
126 let mut owners: HashMap<String, String> = HashMap::new();129 let mut owners: HashMap<String, String> = HashMap::new();
127 for repo in &public {130 for repo in &public {
crates/web/src/repo.rs +31 −14
@@ -1112,19 +1112,29 @@ async fn visible_repos(
1112 if is_owner_or_admin {1112 if is_owner_or_admin {
1113 return Ok(repos);1113 return Ok(repos);
1114 }1114 }
1115 // Non-owners: public repos plus any where they are a collaborator.1115 // Non-owners: whatever the access function grants read to (visibility +
1116 // collaborator rows), so internal repos surface for signed-in viewers.
1117 let viewer_id = viewer.map(|v| auth::Viewer {
1118 id: v.id.clone(),
1119 is_admin: v.is_admin,
1120 });
1116 let mut out = Vec::new();1121 let mut out = Vec::new();
1117 for repo in repos {1122 for repo in repos {
1118 let allowed = !repo.is_private && state.config.instance.allow_anonymous;
1119 let collab = match viewer {1123 let collab = match viewer {
1120 Some(v) => state1124 Some(v) => state
1121 .store1125 .store
1122 .collaborator_permission(&repo.id, &v.id)1126 .collaborator_permission(&repo.id, &v.id)
1123 .await?1127 .await?
1124 .is_some(),1128 .and_then(|p| auth::Permission::parse(&p)),
1125 None => false,1129 None => None,
1126 };1130 };
1127 if allowed || collab {1131 let access = auth::access(
1132 viewer_id.as_ref(),
1133 &repo,
1134 collab,
1135 state.config.instance.allow_anonymous,
1136 );
1137 if access >= auth::Access::Read {
1128 out.push(repo);1138 out.push(repo);
1129 }1139 }
1130 }1140 }
@@ -1137,8 +1147,8 @@ pub(crate) struct RepoEntry {
1137 pub(crate) href: String,1147 pub(crate) href: String,
1138 /// Display label (a bare repo path, or `owner/path` in cross-owner listings).1148 /// Display label (a bare repo path, or `owner/path` in cross-owner listings).
1139 pub(crate) label: String,1149 pub(crate) label: String,
1140 /// Whether the repo is private (drives the badge and visibility subtitle).1150 /// The repo's visibility (drives the badge and subtitle).
1141 pub(crate) private: bool,1151 pub(crate) visibility: model::Visibility,
1142 /// The default branch, shown in the subtitle.1152 /// The default branch, shown in the subtitle.
1143 pub(crate) default_branch: String,1153 pub(crate) default_branch: String,
1144 /// Optional one-line description, shown under the name when present.1154 /// Optional one-line description, shown under the name when present.
@@ -1151,7 +1161,7 @@ impl RepoEntry {
1151 Self {1161 Self {
1152 href: format!("/{owner}/{}", repo.path),1162 href: format!("/{owner}/{}", repo.path),
1153 label: repo.path.clone(),1163 label: repo.path.clone(),
1154 private: repo.is_private,1164 visibility: repo.visibility,
1155 default_branch: repo.default_branch.clone(),1165 default_branch: repo.default_branch.clone(),
1156 description: repo.description.clone(),1166 description: repo.description.clone(),
1157 }1167 }
@@ -1163,13 +1173,22 @@ impl RepoEntry {
1163 Self {1173 Self {
1164 href: format!("/{owner}/{}", repo.path),1174 href: format!("/{owner}/{}", repo.path),
1165 label: format!("{owner}/{}", repo.path),1175 label: format!("{owner}/{}", repo.path),
1166 private: repo.is_private,1176 visibility: repo.visibility,
1167 default_branch: repo.default_branch.clone(),1177 default_branch: repo.default_branch.clone(),
1168 description: repo.description.clone(),1178 description: repo.description.clone(),
1169 }1179 }
1170 }1180 }
1171}1181}
11721182
1183/// The visibility badge shown next to a repo name (nothing for public).
1184fn visibility_badge(visibility: model::Visibility) -> Markup {
1185 html! {
1186 @if visibility != model::Visibility::Public {
1187 span class="badge badge-private" { (visibility.as_str()) }
1188 }
1189 }
1190}
1191
1173/// Render a titled card of repository rows in the reference listing style: a1192/// Render a titled card of repository rows in the reference listing style: a
1174/// card headed by `title`, one bordered row per repo with a bold name and a1193/// card headed by `title`, one bordered row per repo with a bold name and a
1175/// muted `visibility · default: branch` subtitle.1194/// muted `visibility · default: branch` subtitle.
@@ -1186,15 +1205,13 @@ pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Mark
1186 a class="repo-row" href=(e.href) {1205 a class="repo-row" href=(e.href) {
1187 span class="repo-row-name" {1206 span class="repo-row-name" {
1188 (icon(Icon::Box)) span { (e.label) }1207 (icon(Icon::Box)) span { (e.label) }
1189 @if e.private {1208 (visibility_badge(e.visibility))
1190 span class="badge badge-private" { "private" }
1191 }
1192 }1209 }
1193 @if let Some(desc) = &e.description {1210 @if let Some(desc) = &e.description {
1194 span class="repo-row-desc muted" { (desc) }1211 span class="repo-row-desc muted" { (desc) }
1195 }1212 }
1196 span class="repo-row-meta muted" {1213 span class="repo-row-meta muted" {
1197 (if e.private { "private" } else { "public" })1214 (e.visibility.as_str())
1198 " · " (icon(Icon::GitBranch)) " " (e.default_branch)1215 " · " (icon(Icon::GitBranch)) " " (e.default_branch)
1199 }1216 }
1200 }1217 }
@@ -1325,7 +1342,7 @@ fn repo_header(
1325 a href={ "/" (ctx.owner.username) } { (ctx.owner.username) }1342 a href={ "/" (ctx.owner.username) } { (ctx.owner.username) }
1326 span class="slug-sep" { "/" }1343 span class="slug-sep" { "/" }
1327 a href=(base) { (ctx.repo.name) }1344 a href=(base) { (ctx.repo.name) }
1328 @if ctx.repo.is_private { span class="badge badge-private" { "private" } }1345 (visibility_badge(ctx.repo.visibility))
1329 }1346 }
1330 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }1347 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }
1331 div class="repo-nav" {1348 div class="repo-nav" {
crates/web/src/tests.rs +2 −2
@@ -249,7 +249,7 @@ async fn private_repo_is_404_for_anonymous() {
249 name: "secret".to_string(),249 name: "secret".to_string(),
250 path: "secret".to_string(),250 path: "secret".to_string(),
251 description: None,251 description: None,
252 is_private: true,252 visibility: model::Visibility::Private,
253 default_branch: "main".to_string(),253 default_branch: "main".to_string(),
254 })254 })
255 .await255 .await
@@ -305,7 +305,7 @@ async fn explore_lists_public_repos_for_anonymous() {
305 name: "open".to_string(),305 name: "open".to_string(),
306 path: "open".to_string(),306 path: "open".to_string(),
307 description: None,307 description: None,
308 is_private: false,308 visibility: model::Visibility::Public,
309 default_branch: "main".to_string(),309 default_branch: "main".to_string(),
310 })310 })
311 .await311 .await
fabrica.example.toml +1 −0
@@ -20,6 +20,7 @@ url = "https://git.example.com" # canonical external URL, no trailin
20description = "A small, private forge." # one line, shown on the landing page20description = "A small, private forge." # one line, shown on the landing page
21default_branch = "main" # default branch for new repositories21default_branch = "main" # default branch for new repositories
22allow_anonymous = true # anonymous browse + clone of public repos22allow_anonymous = true # anonymous browse + clone of public repos
23default_visibility = "private" # new repos: public | internal | private
2324
24[server]25[server]
25address = "0.0.0.0"26address = "0.0.0.0"
migrations/postgres/0004_repo_visibility.sql +5 −0
@@ -0,0 +1,5 @@
1-- Replace the two-state is_private flag with a three-state visibility token
2-- (public / internal / private), GitLab-style. The is_private column is left in
3-- place but is no longer read or written.
4ALTER TABLE repos ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';
5UPDATE repos SET visibility = CASE WHEN is_private THEN 'private' ELSE 'public' END;
migrations/sqlite/0004_repo_visibility.sql +5 −0
@@ -0,0 +1,5 @@
1-- Replace the two-state is_private flag with a three-state visibility token
2-- (public / internal / private), GitLab-style. The is_private column is left in
3-- place (SQLite cannot easily drop columns) but is no longer read or written.
4ALTER TABLE repos ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';
5UPDATE repos SET visibility = CASE WHEN is_private = 1 THEN 'private' ELSE 'public' END;