fabrica

hanna/fabrica

21840 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! `fabrica repo` — repository administration, operating directly on the store and
6//! the on-disk object storage.
7
8use std::path::Path;
9use std::process::ExitCode;
10
11use clap::Subcommand;
12use model::Repo;
13use store::{NewRepo, Store};
14
15use crate::context::{block_on, hook_binary, open_store};
16use crate::prompt::confirm;
17
18/// Actions under `fabrica repo`.
19#[derive(Debug, Subcommand)]
20pub(crate) enum RepoCommand {
21 /// Create a repository. `name` may include a group path (`backend/api/svc`),
22 /// creating any missing intermediate groups.
23 Add {
24 /// The owning user.
25 user: String,
26 /// The repo name, optionally prefixed with a group path.
27 name: String,
28 /// Make the repo private (owner and collaborators only).
29 #[arg(long, conflicts_with_all = ["public", "internal"])]
30 private: bool,
31 /// Make the repo internal (any signed-in user).
32 #[arg(long, conflicts_with = "public")]
33 internal: bool,
34 /// Make the repo public (everyone).
35 #[arg(long)]
36 public: bool,
37 /// A one-line description.
38 #[arg(long)]
39 description: Option<String>,
40 /// Override the instance default branch.
41 #[arg(long = "default-branch")]
42 default_branch: Option<String>,
43 },
44 /// Delete a repository. Its directory is moved to trash unless `--purge`.
45 Del {
46 /// The owning user.
47 user: String,
48 /// The repo name (group path included).
49 name: String,
50 /// Delete the on-disk repository immediately instead of moving it to trash.
51 #[arg(long)]
52 purge: bool,
53 /// Skip the confirmation prompt (required when stdin is not a terminal).
54 #[arg(long)]
55 yes: bool,
56 },
57 /// List repositories, optionally filtered to one user.
58 List {
59 /// Restrict to a single owner.
60 #[arg(long)]
61 user: Option<String>,
62 },
63 /// Rename a repository (metadata only — the on-disk directory never moves).
64 Rename {
65 /// The owning user.
66 user: String,
67 /// The current repo name (group path included).
68 name: String,
69 /// The new leaf name (the group path is preserved).
70 new_name: String,
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 },
88 /// Manage a repository's collaborators.
89 Collaborator {
90 /// The collaborator action.
91 #[command(subcommand)]
92 command: CollaboratorCommand,
93 },
94 /// Archive a repository (makes it read-only).
95 Archive {
96 /// The owning user.
97 user: String,
98 /// The repo name (group path included).
99 name: String,
100 },
101 /// Unarchive a repository.
102 Unarchive {
103 /// The owning user.
104 user: String,
105 /// The repo name (group path included).
106 name: String,
107 },
108 /// Print the resolved on-disk directory of a repository.
109 Path {
110 /// The owning user.
111 user: String,
112 /// The repo name (group path included).
113 name: String,
114 },
115}
116
117/// Actions under `fabrica repo collaborator`.
118#[derive(Debug, Subcommand)]
119pub(crate) enum CollaboratorCommand {
120 /// Add or update a collaborator's permission.
121 Add {
122 /// The owning user.
123 user: String,
124 /// The repo name (group path included).
125 name: String,
126 /// The collaborator's username.
127 collaborator: String,
128 /// Permission to grant: read | write | admin.
129 #[arg(long, default_value = "write")]
130 permission: String,
131 },
132 /// Remove a collaborator.
133 Rm {
134 /// The owning user.
135 user: String,
136 /// The repo name (group path included).
137 name: String,
138 /// The collaborator's username.
139 collaborator: String,
140 },
141 /// List a repository's collaborators.
142 List {
143 /// The owning user.
144 user: String,
145 /// The repo name (group path included).
146 name: String,
147 },
148}
149
150/// Dispatch a `repo` subcommand.
151pub(crate) fn run(
152 command: &RepoCommand,
153 config_path: Option<&Path>,
154 json: bool,
155) -> anyhow::Result<ExitCode> {
156 match command {
157 RepoCommand::Add {
158 user,
159 name,
160 private,
161 internal,
162 public,
163 description,
164 default_branch,
165 } => block_on(add(
166 config_path,
167 user,
168 name,
169 (*private, *internal, *public),
170 description.clone(),
171 default_branch.clone(),
172 )),
173 RepoCommand::Del {
174 user,
175 name,
176 purge,
177 yes,
178 } => block_on(del(config_path, user, name, *purge, *yes)),
179 RepoCommand::List { user } => block_on(list(config_path, user.as_deref(), json)),
180 RepoCommand::Rename {
181 user,
182 name,
183 new_name,
184 } => block_on(rename(config_path, user, name, new_name)),
185 RepoCommand::Visibility {
186 user,
187 name,
188 private,
189 internal,
190 public,
191 } => block_on(visibility(
192 config_path,
193 user,
194 name,
195 (*private, *internal, *public),
196 )),
197 RepoCommand::Collaborator { command } => match command {
198 CollaboratorCommand::Add {
199 user,
200 name,
201 collaborator,
202 permission,
203 } => block_on(collab_add(
204 config_path,
205 user,
206 name,
207 collaborator,
208 permission,
209 )),
210 CollaboratorCommand::Rm {
211 user,
212 name,
213 collaborator,
214 } => block_on(collab_rm(config_path, user, name, collaborator)),
215 CollaboratorCommand::List { user, name } => {
216 block_on(collab_list(config_path, user, name, json))
217 }
218 },
219 RepoCommand::Archive { user, name } => {
220 block_on(set_archived(config_path, user, name, true))
221 }
222 RepoCommand::Unarchive { user, name } => {
223 block_on(set_archived(config_path, user, name, false))
224 }
225 RepoCommand::Path { user, name } => block_on(path(config_path, user, name)),
226 }
227}
228
229async fn set_archived(
230 config_path: Option<&Path>,
231 user: &str,
232 name: &str,
233 archived: bool,
234) -> anyhow::Result<ExitCode> {
235 let (_config, store) = open_store(config_path).await?;
236 let owner_id = require_user_id(&store, user).await?;
237 let repo = require_repo(&store, &owner_id, name, user).await?;
238 store.set_archived(&repo.id, archived).await?;
239 println!(
240 "{user}/{} is now {}",
241 repo.path,
242 if archived { "archived" } else { "active" }
243 );
244 Ok(ExitCode::SUCCESS)
245}
246
247/// Look up a user's id by name, or error cleanly.
248async fn require_user_id(store: &Store, name: &str) -> anyhow::Result<String> {
249 store
250 .user_by_username(name)
251 .await?
252 .map(|u| u.id)
253 .ok_or_else(|| anyhow::anyhow!("no such user {name:?}"))
254}
255
256/// Look up a repository by owner and path, or error cleanly.
257async fn require_repo(
258 store: &Store,
259 owner_id: &str,
260 path: &str,
261 user: &str,
262) -> anyhow::Result<Repo> {
263 store
264 .repo_by_owner_path(owner_id, path)
265 .await?
266 .ok_or_else(|| anyhow::anyhow!("no such repository {user}/{path}"))
267}
268
269/// Split a repo name into its group-path prefix (if any) and leaf name.
270fn split_group_path(name: &str) -> (Option<&str>, &str) {
271 match name.rsplit_once('/') {
272 Some((group, leaf)) => (Some(group), leaf),
273 None => (None, name),
274 }
275}
276
277/// Resolve the visibility from the `(private, internal, public)` flags, falling
278/// back to the instance default when none is given. clap guarantees at most one
279/// flag is set.
280fn resolve_visibility(
281 flags: (bool, bool, bool),
282 default: config::DefaultVisibility,
283) -> model::Visibility {
284 let (private, internal, public) = flags;
285 if private {
286 model::Visibility::Private
287 } else if internal {
288 model::Visibility::Internal
289 } else if public {
290 model::Visibility::Public
291 } else {
292 model::Visibility::from_token(default.as_token()).unwrap_or(model::Visibility::Private)
293 }
294}
295
296async fn add(
297 config_path: Option<&Path>,
298 user: &str,
299 name: &str,
300 flags: (bool, bool, bool),
301 description: Option<String>,
302 default_branch: Option<String>,
303) -> anyhow::Result<ExitCode> {
304 let (config, store) = open_store(config_path).await?;
305 let owner_id = require_user_id(&store, user).await?;
306 let (group_path, _leaf) = split_group_path(name);
307
308 let group_id = match group_path {
309 Some(gp) => Some(store.ensure_group_path(&owner_id, gp).await?.id),
310 None => None,
311 };
312 let branch = default_branch.unwrap_or_else(|| config.instance.default_branch.clone());
313 let visibility = resolve_visibility(flags, config.instance.default_visibility);
314
315 let repo = store
316 .create_repo(NewRepo {
317 owner_id,
318 group_id,
319 name: split_group_path(name).1.to_string(),
320 path: name.to_string(),
321 description,
322 visibility,
323 default_branch: branch.clone(),
324 })
325 .await?;
326
327 // Create the bare repository on disk; roll the row back if that fails so a
328 // half-created repo never lingers in the database.
329 if let Err(err) = git::create_bare(&config.storage.repo_dir, &repo.id, &branch, &hook_binary())
330 {
331 let _ = store.delete_repo(&repo.id).await;
332 return Err(err.into());
333 }
334
335 println!("created {visibility} repository {user}/{}", repo.path);
336 Ok(ExitCode::SUCCESS)
337}
338
339async fn del(
340 config_path: Option<&Path>,
341 user: &str,
342 name: &str,
343 purge: bool,
344 yes: bool,
345) -> anyhow::Result<ExitCode> {
346 let (config, store) = open_store(config_path).await?;
347 let owner_id = require_user_id(&store, user).await?;
348 let repo = require_repo(&store, &owner_id, name, user).await?;
349
350 let question = if purge {
351 format!("Permanently delete {user}/{} and its history?", repo.path)
352 } else {
353 format!(
354 "Delete {user}/{}? (its directory moves to trash)",
355 repo.path
356 )
357 };
358 if !confirm(&question, yes)? {
359 println!("aborted");
360 return Ok(ExitCode::SUCCESS);
361 }
362
363 store.delete_repo(&repo.id).await?;
364
365 // The database row is gone; now deal with the on-disk directory. A failure
366 // here is logged but does not fail the command — the repo is already
367 // unreachable through fabrica.
368 let dir = git::repo_path(&config.storage.repo_dir, &repo.id)?;
369 if dir.exists() {
370 if purge {
371 if let Err(err) = std::fs::remove_dir_all(&dir) {
372 eprintln!("warning: could not remove {}: {err}", dir.display());
373 }
374 } else {
375 let trash = config.storage.data_dir.join("trash").join(format!(
376 "{}-{}.git",
377 repo.id,
378 store_now()
379 ));
380 if let Some(parent) = trash.parent() {
381 let _ = std::fs::create_dir_all(parent);
382 }
383 if let Err(err) = std::fs::rename(&dir, &trash) {
384 eprintln!("warning: could not move {} to trash: {err}", dir.display());
385 }
386 }
387 }
388
389 println!("deleted repository {user}/{}", repo.path);
390 Ok(ExitCode::SUCCESS)
391}
392
393async fn list(
394 config_path: Option<&Path>,
395 user: Option<&str>,
396 json: bool,
397) -> anyhow::Result<ExitCode> {
398 let (_config, store) = open_store(config_path).await?;
399
400 // Resolve owner id → username once for display.
401 let repos = match user {
402 Some(name) => {
403 let owner_id = require_user_id(&store, name).await?;
404 store.repos_by_owner(&owner_id).await?
405 }
406 None => store.list_repos().await?,
407 };
408
409 // Map owner ids to usernames for the owner column.
410 let mut owners = std::collections::HashMap::new();
411 for repo in &repos {
412 if !owners.contains_key(&repo.owner_id) {
413 let username = store
414 .user_by_id(&repo.owner_id)
415 .await?
416 .map_or_else(|| repo.owner_id.clone(), |u| u.username);
417 owners.insert(repo.owner_id.clone(), username);
418 }
419 }
420
421 if json {
422 let view: Vec<_> = repos
423 .iter()
424 .map(|r| repo_json(r, &owners[&r.owner_id]))
425 .collect();
426 println!("{}", serde_json::to_string_pretty(&view)?);
427 return Ok(ExitCode::SUCCESS);
428 }
429 if repos.is_empty() {
430 println!("no repositories");
431 return Ok(ExitCode::SUCCESS);
432 }
433 for repo in &repos {
434 println!(
435 "{}/{} [{}]",
436 owners[&repo.owner_id], repo.path, repo.visibility
437 );
438 }
439 Ok(ExitCode::SUCCESS)
440}
441
442async fn rename(
443 config_path: Option<&Path>,
444 user: &str,
445 name: &str,
446 new_name: &str,
447) -> anyhow::Result<ExitCode> {
448 let (_config, store) = open_store(config_path).await?;
449 let owner_id = require_user_id(&store, user).await?;
450 let repo = require_repo(&store, &owner_id, name, user).await?;
451
452 // The group prefix is preserved; only the leaf changes.
453 let new_path = match split_group_path(&repo.path).0 {
454 Some(group) => format!("{group}/{new_name}"),
455 None => new_name.to_string(),
456 };
457 store.rename_repo(&repo.id, new_name, &new_path).await?;
458 println!("renamed {user}/{} to {user}/{new_path}", repo.path);
459 Ok(ExitCode::SUCCESS)
460}
461
462async fn visibility(
463 config_path: Option<&Path>,
464 user: &str,
465 name: &str,
466 flags: (bool, bool, bool),
467) -> anyhow::Result<ExitCode> {
468 if flags == (false, false, false) {
469 anyhow::bail!("specify --private, --internal, or --public");
470 }
471 let (_config, store) = open_store(config_path).await?;
472 // A concrete flag is set, so the default is never consulted here.
473 let visibility = resolve_visibility(flags, config::DefaultVisibility::Private);
474 let owner_id = require_user_id(&store, user).await?;
475 let repo = require_repo(&store, &owner_id, name, user).await?;
476 store.set_visibility(&repo.id, visibility).await?;
477 println!("{user}/{} is now {visibility}", repo.path);
478 Ok(ExitCode::SUCCESS)
479}
480
481async fn collab_add(
482 config_path: Option<&Path>,
483 user: &str,
484 name: &str,
485 collaborator: &str,
486 permission: &str,
487) -> anyhow::Result<ExitCode> {
488 if !matches!(permission, "read" | "write" | "admin") {
489 anyhow::bail!("permission must be read, write, or admin");
490 }
491 let (_config, store) = open_store(config_path).await?;
492 let owner_id = require_user_id(&store, user).await?;
493 let repo = require_repo(&store, &owner_id, name, user).await?;
494 let target_id = require_user_id(&store, collaborator).await?;
495 if target_id == owner_id {
496 anyhow::bail!("the owner already has full access; not adding them as a collaborator");
497 }
498 store
499 .set_collaborator(&repo.id, &target_id, permission)
500 .await?;
501 println!(
502 "{collaborator} is now a {permission} collaborator on {user}/{}",
503 repo.path
504 );
505 Ok(ExitCode::SUCCESS)
506}
507
508async fn collab_rm(
509 config_path: Option<&Path>,
510 user: &str,
511 name: &str,
512 collaborator: &str,
513) -> anyhow::Result<ExitCode> {
514 let (_config, store) = open_store(config_path).await?;
515 let owner_id = require_user_id(&store, user).await?;
516 let repo = require_repo(&store, &owner_id, name, user).await?;
517 let target_id = require_user_id(&store, collaborator).await?;
518 if store.remove_collaborator(&repo.id, &target_id).await? {
519 println!("removed {collaborator} from {user}/{}", repo.path);
520 } else {
521 println!(
522 "{collaborator} was not a collaborator on {user}/{}",
523 repo.path
524 );
525 }
526 Ok(ExitCode::SUCCESS)
527}
528
529async fn collab_list(
530 config_path: Option<&Path>,
531 user: &str,
532 name: &str,
533 json: bool,
534) -> anyhow::Result<ExitCode> {
535 let (_config, store) = open_store(config_path).await?;
536 let owner_id = require_user_id(&store, user).await?;
537 let repo = require_repo(&store, &owner_id, name, user).await?;
538 let collaborators = store.list_collaborators(&repo.id).await?;
539 if json {
540 let view: Vec<_> = collaborators
541 .iter()
542 .map(|c| serde_json::json!({ "username": c.username, "permission": c.permission }))
543 .collect();
544 println!("{}", serde_json::to_string_pretty(&view)?);
545 return Ok(ExitCode::SUCCESS);
546 }
547 if collaborators.is_empty() {
548 println!("no collaborators");
549 return Ok(ExitCode::SUCCESS);
550 }
551 for c in &collaborators {
552 println!("{} [{}]", c.username, c.permission);
553 }
554 Ok(ExitCode::SUCCESS)
555}
556
557async fn path(config_path: Option<&Path>, user: &str, name: &str) -> anyhow::Result<ExitCode> {
558 let (config, store) = open_store(config_path).await?;
559 let owner_id = require_user_id(&store, user).await?;
560 let repo = require_repo(&store, &owner_id, name, user).await?;
561 let dir = git::repo_path(&config.storage.repo_dir, &repo.id)?;
562 println!("{}", dir.display());
563 Ok(ExitCode::SUCCESS)
564}
565
566/// A millisecond timestamp for trash-directory names. Kept local so the module has
567/// no direct dependency on the store's private `now_ms`.
568fn store_now() -> i64 {
569 use std::time::{SystemTime, UNIX_EPOCH};
570 SystemTime::now()
571 .duration_since(UNIX_EPOCH)
572 .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
573}
574
575/// Render a repo for `--json`.
576fn repo_json(repo: &Repo, owner: &str) -> serde_json::Value {
577 serde_json::json!({
578 "id": repo.id,
579 "owner": owner,
580 "name": repo.name,
581 "path": repo.path,
582 "description": repo.description,
583 "visibility": repo.visibility.as_str(),
584 "default_branch": repo.default_branch,
585 "created_at": repo.created_at,
586 })
587}
588
589#[cfg(test)]
590mod tests {
591 #![allow(clippy::unwrap_used)]
592
593 use super::*;
594 use crate::testutil::env;
595
596 #[test]
597 fn add_creates_row_and_bare_repo_under_a_group() {
598 let env = env();
599 block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap();
600
601 // A grouped repo auto-creates the intermediate group and a bare repo dir.
602 block_on(add(
603 env.path(),
604 "ada",
605 "backend/svc",
606 (true, false, false),
607 None,
608 None,
609 ))
610 .unwrap();
611
612 let (repo, dir) = block_on(async {
613 let store = env.store().await;
614 let owner = require_user_id(&store, "ada").await?;
615 let repo = require_repo(&store, &owner, "backend/svc", "ada").await?;
616 let group = store.group_by_owner_path(&owner, "backend").await?;
617 assert!(group.is_some(), "intermediate group should exist");
618 let cfg = config::load(env.path())?.config;
619 let dir = git::repo_path(&cfg.storage.repo_dir, &repo.id)?;
620 anyhow::Ok((repo, dir))
621 })
622 .unwrap();
623 assert_eq!(repo.path, "backend/svc");
624 assert_eq!(repo.visibility, model::Visibility::Private);
625 assert!(dir.join("HEAD").exists(), "a bare repo was created on disk");
626
627 // Rename preserves the group prefix.
628 block_on(rename(env.path(), "ada", "backend/svc", "service")).unwrap();
629 let renamed = block_on(async {
630 let store = env.store().await;
631 let owner = require_user_id(&store, "ada").await?;
632 anyhow::Ok(store.repo_by_owner_path(&owner, "backend/service").await?)
633 })
634 .unwrap();
635 assert!(renamed.is_some());
636
637 // Delete moves the directory to trash (not --purge).
638 block_on(del(env.path(), "ada", "backend/service", false, true)).unwrap();
639 assert!(!dir.exists(), "the directory moved out to trash");
640 let gone = block_on(async {
641 let store = env.store().await;
642 let owner = require_user_id(&store, "ada").await?;
643 anyhow::Ok(store.repo_by_owner_path(&owner, "backend/service").await?)
644 })
645 .unwrap();
646 assert!(gone.is_none());
647 }
648
649 #[test]
650 fn add_rolls_back_the_row_if_the_disk_repo_cannot_be_created() {
651 let env = env();
652 block_on(async { anyhow::Ok(env.make_user("eve").await) }).unwrap();
653 block_on(add(
654 env.path(),
655 "eve",
656 "proj",
657 (true, false, false),
658 None,
659 None,
660 ))
661 .unwrap();
662 // A second create at the same id path is impossible, but a duplicate
663 // logical repo must conflict at the store layer and leave nothing behind.
664 let dup = block_on(add(
665 env.path(),
666 "eve",
667 "proj",
668 (true, false, false),
669 None,
670 None,
671 ));
672 assert!(dup.is_err());
673 }
674}