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(
143143 name: leaf,
144144 path: body.name.clone(),
145145 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+ },
147151 default_branch: branch.clone(),
148152 })
149153 .await?;
@@ -483,7 +487,8 @@ fn repo_json(repo: &Repo, owner: &str) -> Value {
483487 "owner": owner,
484488 "name": repo.name,
485489 "path": repo.path,
486- "private": repo.is_private,
490+ "private": repo.visibility.is_private(),
491+ "visibility": repo.visibility.as_str(),
487492 "default_branch": repo.default_branch,
488493 "description": repo.description,
489494 "archived": repo.archived_at.is_some(),
crates/auth/src/access.rs +69 −57
@@ -12,7 +12,7 @@
1212 //! [`Access::None`] on a private repo renders `404`, never `403`, so existence
1313 //! does not leak — belong to the web and API layers.
1414
15-use model::Repo;
15+use model::{Repo, Visibility};
1616
1717 /// The access level a viewer has to a repository, in increasing order of power.
1818 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
@@ -95,15 +95,15 @@ pub struct Viewer {
9595 /// 1. a site admin gets [`Access::Admin`];
9696 /// 2. the repo's owner gets [`Access::Admin`];
9797 /// 3. an explicit collaborator gets exactly their stored permission;
98-/// 4. a public repo, when the instance allows anonymous access, grants
99-/// [`Access::Read`] to anyone;
100-/// 5. otherwise [`Access::None`].
98+/// 4. otherwise the repo's [`Visibility`] decides:
99+/// - **public**: [`Access::Read`] for any signed-in viewer, and for anonymous
100+/// viewers when the instance allows anonymous access;
101+/// - **internal**: [`Access::Read`] for any signed-in viewer, never anonymous;
102+/// - **private**: [`Access::None`].
101103 ///
102104 /// `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`.
104-/// Note rule 4 is gated on that flag for *every* viewer, so disabling anonymous
105-/// access closes public repos to non-collaborators as well — matching the spec's
106-/// private-by-default posture.
105+/// any; the caller looks it up. `allow_anonymous` is `instance.allow_anonymous`
106+/// and now gates only anonymous access to *public* repos.
107107 #[must_use]
108108 pub fn access(
109109 viewer: Option<&Viewer>,
@@ -122,10 +122,11 @@ pub fn access(
122122 return permission.into();
123123 }
124124 }
125- if !repo.is_private && allow_anonymous {
126- return Access::Read;
125+ match repo.visibility {
126+ Visibility::Public if viewer.is_some() || allow_anonymous => Access::Read,
127+ Visibility::Internal if viewer.is_some() => Access::Read,
128+ _ => Access::None,
127129 }
128- Access::None
129130 }
130131
131132 #[cfg(test)]
@@ -136,7 +137,7 @@ mod tests {
136137
137138 /// A repo owned by `owner`, with the given visibility. Only the fields
138139 /// `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 {
140141 Repo {
141142 id: "01reporeporeporeporeporepo".to_string(),
142143 owner_id: owner.to_string(),
@@ -144,7 +145,7 @@ mod tests {
144145 name: "r".to_string(),
145146 path: "r".to_string(),
146147 description: None,
147- is_private,
148+ visibility,
148149 default_branch: "main".to_string(),
149150 archived_at: None,
150151 size_bytes: 0,
@@ -154,6 +155,13 @@ mod tests {
154155 }
155156 }
156157
158+ /// Every visibility, for exhaustive iteration.
159+ const ALL_VIS: [Visibility; 3] = [
160+ Visibility::Public,
161+ Visibility::Internal,
162+ Visibility::Private,
163+ ];
164+
157165 fn viewer(id: &str, is_admin: bool) -> Viewer {
158166 Viewer {
159167 id: id.to_string(),
@@ -180,7 +188,7 @@ mod tests {
180188 struct Case {
181189 viewer: Option<Viewer>,
182190 collaborator: Option<Permission>,
183- is_private: bool,
191+ visibility: Visibility,
184192 allow_anonymous: bool,
185193 expect: Access,
186194 }
@@ -198,82 +206,73 @@ mod tests {
198206 let mut cases = Vec::new();
199207 let mut push = |viewer: Option<&Viewer>,
200208 collaborator: Option<Permission>,
201- is_private: bool,
209+ visibility: Visibility,
202210 allow_anonymous: bool,
203211 expect: Access| {
204212 cases.push(Case {
205213 viewer: viewer.cloned(),
206214 collaborator,
207- is_private,
215+ visibility,
208216 allow_anonymous,
209217 expect,
210218 });
211219 };
212220
213- // Rule 1: a site admin is Admin everywhere, regardless of every other
214- // dimension.
215- for &private in &[true, false] {
221+ for &vis in &ALL_VIS {
216222 for &anon in &[true, false] {
223+ // Rule 1: a site admin is Admin everywhere, regardless of every
224+ // other dimension.
217225 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);
219227 }
220- }
221- }
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] {
228+ // Rule 2: the owner is Admin everywhere, even with a lesser
229+ // collaborator row present and anonymous access off.
227230 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);
229232 }
230- }
231- }
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] {
233+ // Rule 3: an explicit collaborator gets exactly their permission,
234+ // beating visibility and the anonymous flag.
238235 push(
239236 Some(&other),
240237 Some(Permission::Read),
241- private,
238+ vis,
242239 anon,
243240 Access::Read,
244241 );
245242 push(
246243 Some(&other),
247244 Some(Permission::Write),
248- private,
245+ vis,
249246 anon,
250247 Access::Write,
251248 );
252249 push(
253250 Some(&other),
254251 Some(Permission::Admin),
255- private,
252+ vis,
256253 anon,
257254 Access::Admin,
258255 );
259256 }
260257 }
261258
262- // Rule 4 / 5: a non-collaborator (authenticated stranger or anonymous)
263- // reads a public repo only when anonymous access is allowed; a private
264- // repo is always None; and anon-off closes public repos too.
265- for viewer in [Some(&other), None] {
266- // Public repo, anonymous allowed -> Read.
267- push(viewer, None, false, true, Access::Read);
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);
259+ // Rule 4: a non-collaborator, by visibility.
260+ // Signed-in stranger: reads public and internal always; never private.
261+ for &anon in &[true, false] {
262+ push(Some(&other), None, Visibility::Public, anon, Access::Read);
263+ push(Some(&other), None, Visibility::Internal, anon, Access::Read);
264+ push(Some(&other), None, Visibility::Private, anon, Access::None);
273265 }
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
275274 for case in &cases {
276- let repo = repo("owner", case.is_private);
275+ let repo = repo("owner", case.visibility);
277276 let got = access(
278277 case.viewer.as_ref(),
279278 &repo,
@@ -282,23 +281,36 @@ mod tests {
282281 );
283282 assert_eq!(
284283 got, case.expect,
285- "viewer={:?} collab={:?} private={} anon={}",
286- case.viewer, case.collaborator, case.is_private, case.allow_anonymous
284+ "viewer={:?} collab={:?} vis={} anon={}",
285+ case.viewer, case.collaborator, case.visibility, case.allow_anonymous
287286 );
288287 }
289288 }
290289
291290 #[test]
292291 fn private_repo_never_leaks_to_a_stranger() {
293- // The security-critical invariant, called out on its own: no combination
294- // of a non-privileged stranger on a private repo yields anything but None.
292+ // The security-critical invariant: no combination of a non-privileged
293+ // stranger on a private repo yields anything but None. Internal repos also
294+ // never leak to anonymous visitors.
295295 let stranger = viewer("stranger", false);
296296 for &anon in &[true, false] {
297297 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),
299312 Access::None
300313 );
301- assert_eq!(access(None, &repo("owner", true), None, anon), Access::None);
302314 }
303315 }
304316 }
crates/cli/src/repo_cmd.rs +108 −22
@@ -25,10 +25,13 @@ pub(crate) enum RepoCommand {
2525 user: String,
2626 /// The repo name, optionally prefixed with a group path.
2727 name: String,
28- /// Make the repo private (the default).
29- #[arg(long, conflicts_with = "public")]
28+ /// Make the repo private (owner and collaborators only).
29+ #[arg(long, conflicts_with_all = ["public", "internal"])]
3030 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).
3235 #[arg(long)]
3336 public: bool,
3437 /// A one-line description.
@@ -66,6 +69,22 @@ pub(crate) enum RepoCommand {
6669 /// The new leaf name (the group path is preserved).
6770 new_name: String,
6871 },
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+ },
6988 /// Print the resolved on-disk directory of a repository.
7089 Path {
7190 /// The owning user.
@@ -85,7 +104,8 @@ pub(crate) fn run(
85104 RepoCommand::Add {
86105 user,
87106 name,
88- private: _private,
107+ private,
108+ internal,
89109 public,
90110 description,
91111 default_branch,
@@ -93,9 +113,7 @@ pub(crate) fn run(
93113 config_path,
94114 user,
95115 name,
96- // Private is the default; `--private` is the explicit form of it, and
97- // conflicts with `--public`, so visibility is simply "not public".
98- !*public,
116+ (*private, *internal, *public),
99117 description.clone(),
100118 default_branch.clone(),
101119 )),
@@ -111,6 +129,18 @@ pub(crate) fn run(
111129 name,
112130 new_name,
113131 } => 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+ )),
114144 RepoCommand::Path { user, name } => block_on(path(config_path, user, name)),
115145 }
116146 }
@@ -145,11 +175,30 @@ fn split_group_path(name: &str) -> (Option<&str>, &str) {
145175 }
146176 }
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.
181+fn 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+
148197 async fn add(
149198 config_path: Option<&Path>,
150199 user: &str,
151200 name: &str,
152- is_private: bool,
201+ flags: (bool, bool, bool),
153202 description: Option<String>,
154203 default_branch: Option<String>,
155204 ) -> anyhow::Result<ExitCode> {
@@ -162,6 +211,7 @@ async fn add(
162211 None => None,
163212 };
164213 let branch = default_branch.unwrap_or_else(|| config.instance.default_branch.clone());
214+ let visibility = resolve_visibility(flags, config.instance.default_visibility);
165215
166216 let repo = store
167217 .create_repo(NewRepo {
@@ -170,7 +220,7 @@ async fn add(
170220 name: split_group_path(name).1.to_string(),
171221 path: name.to_string(),
172222 description,
173- is_private,
223+ visibility,
174224 default_branch: branch.clone(),
175225 })
176226 .await?;
@@ -183,11 +233,7 @@ async fn add(
183233 return Err(err.into());
184234 }
185235
186- println!(
187- "created {} repository {user}/{}",
188- if is_private { "private" } else { "public" },
189- repo.path
190- );
236+ println!("created {visibility} repository {user}/{}", repo.path);
191237 Ok(ExitCode::SUCCESS)
192238 }
193239
@@ -288,9 +334,7 @@ async fn list(
288334 for repo in &repos {
289335 println!(
290336 "{}/{} [{}]",
291- owners[&repo.owner_id],
292- repo.path,
293- if repo.is_private { "private" } else { "public" }
337+ owners[&repo.owner_id], repo.path, repo.visibility
294338 );
295339 }
296340 Ok(ExitCode::SUCCESS)
@@ -316,6 +360,25 @@ async fn rename(
316360 Ok(ExitCode::SUCCESS)
317361 }
318362
363+async 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+
319382 async fn path(config_path: Option<&Path>, user: &str, name: &str) -> anyhow::Result<ExitCode> {
320383 let (config, store) = open_store(config_path).await?;
321384 let owner_id = require_user_id(&store, user).await?;
@@ -342,7 +405,7 @@ fn repo_json(repo: &Repo, owner: &str) -> serde_json::Value {
342405 "name": repo.name,
343406 "path": repo.path,
344407 "description": repo.description,
345- "private": repo.is_private,
408+ "visibility": repo.visibility.as_str(),
346409 "default_branch": repo.default_branch,
347410 "created_at": repo.created_at,
348411 })
@@ -361,7 +424,15 @@ mod tests {
361424 block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap();
362425
363426 // 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
366437 let (repo, dir) = block_on(async {
367438 let store = env.store().await;
@@ -375,7 +446,7 @@ mod tests {
375446 })
376447 .unwrap();
377448 assert_eq!(repo.path, "backend/svc");
378- assert!(repo.is_private);
449+ assert_eq!(repo.visibility, model::Visibility::Private);
379450 assert!(dir.join("HEAD").exists(), "a bare repo was created on disk");
380451
381452 // Rename preserves the group prefix.
@@ -404,10 +475,25 @@ mod tests {
404475 fn add_rolls_back_the_row_if_the_disk_repo_cannot_be_created() {
405476 let env = env();
406477 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();
408487 // A second create at the same id path is impossible, but a duplicate
409488 // 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+ ));
411497 assert!(dup.is_err());
412498 }
413499 }
crates/cli/src/user_cmd.rs +1 −1
@@ -521,7 +521,7 @@ mod tests {
521521 name: "proj".to_string(),
522522 path: "proj".to_string(),
523523 description: None,
524- is_private: true,
524+ visibility: model::Visibility::Private,
525525 default_branch: "main".to_string(),
526526 })
527527 .await?;
crates/config/src/lib.rs +2 −2
@@ -32,8 +32,8 @@ use figment::providers::{Env, Format, Serialized, Toml};
3232
3333 pub use crate::secret::{REDACTED, Secret};
3434 pub use crate::types::{
35- Auth, Config, Database, Git, Instance, Log, LogFormat, LogLevel, Mail, MailBackend,
36- MailEncryption, Search, Server, Ssh, Storage, Ui,
35+ Auth, Config, Database, DefaultVisibility, Git, Instance, Log, LogFormat, LogLevel, Mail,
36+ MailBackend, MailEncryption, Search, Server, Ssh, Storage, Ui,
3737 };
3838
3939 /// System-wide configuration path, the lowest-priority file source.
crates/config/src/types.rs +29 −0
@@ -64,6 +64,31 @@ impl Default for Search {
6464 }
6565 }
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")]
70+pub 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+
80+impl 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+
6792 /// `[instance]` — identity and global policy.
6893 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6994 #[serde(deny_unknown_fields)]
@@ -78,6 +103,9 @@ pub struct Instance {
78103 pub default_branch: String,
79104 /// Allow anonymous browse and clone of public repositories.
80105 pub allow_anonymous: bool,
106+ /// Default visibility for newly created repositories.
107+ #[serde(default)]
108+ pub default_visibility: DefaultVisibility,
81109 }
82110
83111 impl Default for Instance {
@@ -88,6 +116,7 @@ impl Default for Instance {
88116 description: String::new(),
89117 default_branch: "main".to_string(),
90118 allow_anonymous: true,
119+ default_visibility: DefaultVisibility::Private,
91120 }
92121 }
93122 }
crates/model/src/entity.rs +50 −2
@@ -54,6 +54,54 @@ impl std::fmt::Display for KeyKind {
5454 }
5555 }
5656
57+/// A repository's visibility, GitLab-style. Stored as the `repos.visibility`
58+/// text token.
59+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60+pub 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+
70+impl 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+
99+impl 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+
57105 /// A registered account.
58106 #[derive(Debug, Clone, PartialEq, Eq)]
59107 pub struct User {
@@ -195,8 +243,8 @@ pub struct Repo {
195243 pub path: String,
196244 /// Optional one-line description.
197245 pub description: Option<String>,
198- /// Private repositories are invisible to non-collaborators.
199- pub is_private: bool,
246+ /// Who may see the repository (public / internal / private).
247+ pub visibility: Visibility,
200248 /// The branch shown by default and used for HEAD.
201249 pub default_branch: String,
202250 /// When the repo was archived (read-only), if it is.
crates/model/src/lib.rs +1 −1
@@ -11,5 +11,5 @@
1111 pub mod entity;
1212 pub mod name;
1313
14-pub use entity::{ApiToken, Group, Invite, Key, KeyKind, Repo, Timestamp, User};
14+pub use entity::{ApiToken, Group, Invite, Key, KeyKind, Repo, Timestamp, User, Visibility};
1515 pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};
crates/store/src/repos.rs +34 −8
@@ -4,14 +4,14 @@
44
55 //! Repositories: creation and path lookup.
66
7-use model::Repo;
7+use model::{Repo, Visibility};
88 use sqlx::any::AnyRow;
99 use sqlx::{AssertSqlSafe, Row};
1010
1111 use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1212
1313 /// The columns of `repos` in a fixed order, shared by every `SELECT`.
14-const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, is_private, \
14+const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \
1515 default_branch, archived_at, size_bytes, pushed_at, created_at, updated_at";
1616
1717 /// Fields needed to create a repository. The server fills in the id, timestamps,
@@ -28,8 +28,8 @@ pub struct NewRepo {
2828 pub path: String,
2929 /// Optional one-line description.
3030 pub description: Option<String>,
31- /// Whether the repo is private (invisible to non-collaborators).
32- pub is_private: bool,
31+ /// The repo's visibility (public / internal / private).
32+ pub visibility: Visibility,
3333 /// The repo's default branch.
3434 pub default_branch: String,
3535 }
@@ -52,7 +52,7 @@ impl Store {
5252 name: new.name,
5353 path: new.path,
5454 description: new.description,
55- is_private: new.is_private,
55+ visibility: new.visibility,
5656 default_branch: new.default_branch,
5757 archived_at: None,
5858 size_bytes: 0,
@@ -62,7 +62,7 @@ impl Store {
6262 };
6363
6464 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, \
6666 archived_at, size_bytes, pushed_at, created_at, updated_at) \
6767 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)";
6868 sqlx::query(sql)
@@ -72,7 +72,7 @@ impl Store {
7272 .bind(repo.name.clone())
7373 .bind(repo.path.clone())
7474 .bind(repo.description.clone())
75- .bind(repo.is_private)
75+ .bind(repo.visibility.as_str())
7676 .bind(repo.default_branch.clone())
7777 .bind(repo.archived_at)
7878 .bind(repo.size_bytes)
@@ -182,6 +182,28 @@ impl Store {
182182 Ok(updated > 0)
183183 }
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+
185207 /// Record that a repository was just pushed to: stamp `pushed_at` and refresh
186208 /// `size_bytes`. Called by `fabrica hook post-receive`.
187209 ///
@@ -240,6 +262,9 @@ impl Store {
240262 }
241263
242264 /// 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)]
243268 fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {
244269 Ok(Repo {
245270 id: row.try_get("id")?,
@@ -248,7 +273,8 @@ impl Store {
248273 name: row.try_get("name")?,
249274 path: row.try_get("path")?,
250275 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),
252278 default_branch: row.try_get("default_branch")?,
253279 archived_at: row.try_get("archived_at")?,
254280 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() {
358358 name: "r".to_string(),
359359 path: "r".to_string(),
360360 description: None,
361- is_private: true,
361+ visibility: model::Visibility::Private,
362362 default_branch: "main".to_string(),
363363 })
364364 .await
@@ -390,7 +390,7 @@ async fn repos_by_owner_lists_in_path_order() {
390390 name: name.to_string(),
391391 path: name.to_string(),
392392 description: None,
393- is_private: true,
393+ visibility: model::Visibility::Private,
394394 default_branch: "main".to_string(),
395395 })
396396 .await
@@ -425,14 +425,14 @@ async fn create_and_fetch_repo() {
425425 name: "project".to_string(),
426426 path: "project".to_string(),
427427 description: Some("a repo".to_string()),
428- is_private: false,
428+ visibility: model::Visibility::Public,
429429 default_branch: "main".to_string(),
430430 })
431431 .await
432432 .unwrap();
433433 assert_eq!(repo.size_bytes, 0);
434434 assert!(repo.pushed_at.is_none());
435- assert!(!repo.is_private);
435+ assert_eq!(repo.visibility, model::Visibility::Public);
436436
437437 let fetched = fx
438438 .store
@@ -451,7 +451,7 @@ async fn create_and_fetch_repo() {
451451 name: "project".to_string(),
452452 path: "project".to_string(),
453453 description: None,
454- is_private: true,
454+ visibility: model::Visibility::Private,
455455 default_branch: "main".to_string(),
456456 })
457457 .await
@@ -482,7 +482,7 @@ async fn foreign_keys_are_enforced() {
482482 name: "orphan".to_string(),
483483 path: "orphan".to_string(),
484484 description: None,
485- is_private: true,
485+ visibility: model::Visibility::Private,
486486 default_branch: "main".to_string(),
487487 })
488488 .await
@@ -767,7 +767,7 @@ async fn rename_and_delete_repo() {
767767 name: "old".to_string(),
768768 path: "old".to_string(),
769769 description: None,
770- is_private: true,
770+ visibility: model::Visibility::Private,
771771 default_branch: "main".to_string(),
772772 })
773773 .await
@@ -944,7 +944,7 @@ async fn postgres_round_trip() {
944944 name: "pgproj".to_string(),
945945 path: "pgproj".to_string(),
946946 description: None,
947- is_private: false,
947+ visibility: model::Visibility::Public,
948948 default_branch: "main".to_string(),
949949 })
950950 .await
@@ -955,7 +955,7 @@ async fn postgres_round_trip() {
955955 .unwrap()
956956 .unwrap();
957957 assert_eq!(fetched_repo, repo);
958- assert!(!fetched_repo.is_private);
958+ assert_eq!(fetched_repo.visibility, model::Visibility::Public);
959959
960960 // Clean up (repo cascades with the user).
961961 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 {
121121 async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> {
122122 let needle = q.trim().to_lowercase();
123123 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();
125128 // Resolve owner names once for the cross-owner labels/links and search.
126129 let mut owners: HashMap<String, String> = HashMap::new();
127130 for repo in &public {
crates/web/src/repo.rs +31 −14
@@ -1112,19 +1112,29 @@ async fn visible_repos(
11121112 if is_owner_or_admin {
11131113 return Ok(repos);
11141114 }
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+ });
11161121 let mut out = Vec::new();
11171122 for repo in repos {
1118- let allowed = !repo.is_private && state.config.instance.allow_anonymous;
11191123 let collab = match viewer {
11201124 Some(v) => state
11211125 .store
11221126 .collaborator_permission(&repo.id, &v.id)
11231127 .await?
1124- .is_some(),
1125- None => false,
1128+ .and_then(|p| auth::Permission::parse(&p)),
1129+ None => None,
11261130 };
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 {
11281138 out.push(repo);
11291139 }
11301140 }
@@ -1137,8 +1147,8 @@ pub(crate) struct RepoEntry {
11371147 pub(crate) href: String,
11381148 /// Display label (a bare repo path, or `owner/path` in cross-owner listings).
11391149 pub(crate) label: String,
1140- /// Whether the repo is private (drives the badge and visibility subtitle).
1141- pub(crate) private: bool,
1150+ /// The repo's visibility (drives the badge and subtitle).
1151+ pub(crate) visibility: model::Visibility,
11421152 /// The default branch, shown in the subtitle.
11431153 pub(crate) default_branch: String,
11441154 /// Optional one-line description, shown under the name when present.
@@ -1151,7 +1161,7 @@ impl RepoEntry {
11511161 Self {
11521162 href: format!("/{owner}/{}", repo.path),
11531163 label: repo.path.clone(),
1154- private: repo.is_private,
1164+ visibility: repo.visibility,
11551165 default_branch: repo.default_branch.clone(),
11561166 description: repo.description.clone(),
11571167 }
@@ -1163,13 +1173,22 @@ impl RepoEntry {
11631173 Self {
11641174 href: format!("/{owner}/{}", repo.path),
11651175 label: format!("{owner}/{}", repo.path),
1166- private: repo.is_private,
1176+ visibility: repo.visibility,
11671177 default_branch: repo.default_branch.clone(),
11681178 description: repo.description.clone(),
11691179 }
11701180 }
11711181 }
11721182
1183+/// The visibility badge shown next to a repo name (nothing for public).
1184+fn 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+
11731192 /// Render a titled card of repository rows in the reference listing style: a
11741193 /// card headed by `title`, one bordered row per repo with a bold name and a
11751194 /// muted `visibility · default: branch` subtitle.
@@ -1186,15 +1205,13 @@ pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Mark
11861205 a class="repo-row" href=(e.href) {
11871206 span class="repo-row-name" {
11881207 (icon(Icon::Box)) span { (e.label) }
1189- @if e.private {
1190- span class="badge badge-private" { "private" }
1191- }
1208+ (visibility_badge(e.visibility))
11921209 }
11931210 @if let Some(desc) = &e.description {
11941211 span class="repo-row-desc muted" { (desc) }
11951212 }
11961213 span class="repo-row-meta muted" {
1197- (if e.private { "private" } else { "public" })
1214+ (e.visibility.as_str())
11981215 " · " (icon(Icon::GitBranch)) " " (e.default_branch)
11991216 }
12001217 }
@@ -1325,7 +1342,7 @@ fn repo_header(
13251342 a href={ "/" (ctx.owner.username) } { (ctx.owner.username) }
13261343 span class="slug-sep" { "/" }
13271344 a href=(base) { (ctx.repo.name) }
1328- @if ctx.repo.is_private { span class="badge badge-private" { "private" } }
1345+ (visibility_badge(ctx.repo.visibility))
13291346 }
13301347 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }
13311348 div class="repo-nav" {
crates/web/src/tests.rs +2 −2
@@ -249,7 +249,7 @@ async fn private_repo_is_404_for_anonymous() {
249249 name: "secret".to_string(),
250250 path: "secret".to_string(),
251251 description: None,
252- is_private: true,
252+ visibility: model::Visibility::Private,
253253 default_branch: "main".to_string(),
254254 })
255255 .await
@@ -305,7 +305,7 @@ async fn explore_lists_public_repos_for_anonymous() {
305305 name: "open".to_string(),
306306 path: "open".to_string(),
307307 description: None,
308- is_private: false,
308+ visibility: model::Visibility::Public,
309309 default_branch: "main".to_string(),
310310 })
311311 .await
fabrica.example.toml +1 −0
@@ -20,6 +20,7 @@ url = "https://git.example.com" # canonical external URL, no trailin
2020 description = "A small, private forge." # one line, shown on the landing page
2121 default_branch = "main" # default branch for new repositories
2222 allow_anonymous = true # anonymous browse + clone of public repos
23+default_visibility = "private" # new repos: public | internal | private
2324
2425 [server]
2526 address = "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.
4+ALTER TABLE repos ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';
5+UPDATE 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.
4+ALTER TABLE repos ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';
5+UPDATE repos SET visibility = CASE WHEN is_private = 1 THEN 'private' ELSE 'public' END;