Commit

Author:

Hash:

Timestamp:

+581 -97 +/-9 browse

Kevin Schoon [me@kevinschoon.com]

7484d5b6a2a52872b5764c44d87e1f8c155e34d7

Fri, 18 Jul 2025 08:57:58 +0000 (4 months ago)

implement basic shell interface
implement basic shell interface

This adds a new user-friendly interface for managing remote repositories over
an SSH connection attached to a shell.
1diff --git a/Cargo.lock b/Cargo.lock
2index feb3abb..b2dd797 100644
3--- a/Cargo.lock
4+++ b/Cargo.lock
5 @@ -385,10 +385,14 @@ name = "ayllu-shell"
6 version = "0.1.0"
7 dependencies = [
8 "ayllu_config",
9+ "ayllu_git",
10 "clap 4.5.41",
11 "dialoguer",
12+ "nix",
13 "serde",
14+ "thiserror 2.0.12",
15 "tracing",
16+ "url",
17 ]
18
19 [[package]]
20 diff --git a/ayllu-shell/Cargo.toml b/ayllu-shell/Cargo.toml
21index 3422202..76f0b2b 100644
22--- a/ayllu-shell/Cargo.toml
23+++ b/ayllu-shell/Cargo.toml
24 @@ -6,8 +6,12 @@ edition = "2021"
25 [dependencies]
26
27 ayllu_config = { path = "../crates/config" }
28+ ayllu_git = { path = "../crates/git"}
29
30 clap = { workspace = true }
31 dialoguer = { version = "0.11.0", default-features = false }
32+ nix = { version = "0.30.1", default-features = false, features = ["user"] }
33 serde = { workspace = true }
34+ thiserror.workspace = true
35 tracing = { workspace = true }
36+ url = "2.5.4"
37 diff --git a/ayllu-shell/src/config.rs b/ayllu-shell/src/config.rs
38index ee43ab6..c1709fd 100644
39--- a/ayllu-shell/src/config.rs
40+++ b/ayllu-shell/src/config.rs
41 @@ -1,14 +1,35 @@
42- use std::path::PathBuf;
43+ use std::path::{Path, PathBuf};
44
45 use ayllu_config::Configurable;
46 use serde::{Deserialize, Serialize};
47
48+ #[derive(Default, Deserialize, Serialize, Clone, Debug)]
49+ pub struct Collection {
50+ pub name: String,
51+ pub description: Option<String>,
52+ pub path: PathBuf,
53+ pub hidden: Option<bool>,
54+ }
55+
56 /// An identity defines a known user in the Ayllu environment
57 #[derive(Serialize, Deserialize, Clone)]
58 pub struct Identity {
59 pub username: String,
60- pub shell: Option<PathBuf>,
61+ #[serde(default = "Identity::default_shell")]
62+ pub shell: PathBuf,
63 pub authorized_keys: Option<Vec<String>>,
64+ #[serde(default = "Identity::default_repositories_path")]
65+ pub repositories_path: PathBuf,
66+ }
67+
68+ impl Identity {
69+ fn default_shell() -> PathBuf {
70+ Path::new("/bin/sh").to_path_buf()
71+ }
72+
73+ fn default_repositories_path() -> PathBuf {
74+ Path::new("repos").to_path_buf()
75+ }
76 }
77
78 /// Various Shell configuration
79 @@ -24,6 +45,8 @@ pub struct Config {
80 pub identities: Vec<Identity>,
81 #[serde(default = "Shell::default")]
82 pub shell: Shell,
83+ #[serde(default = "Vec::new")]
84+ pub collections: Vec<Collection>,
85 }
86
87 impl Configurable for Config {}
88 diff --git a/ayllu-shell/src/error.rs b/ayllu-shell/src/error.rs
89new file mode 100644
90index 0000000..e88a943
91--- /dev/null
92+++ b/ayllu-shell/src/error.rs
93 @@ -0,0 +1,7 @@
94+ #[derive(Debug, thiserror::Error)]
95+ pub enum Error {
96+ #[error("Git: {0}")]
97+ Git(#[from] ayllu_git::Error),
98+ #[error("Io: {0}")]
99+ Io(#[from] std::io::Error),
100+ }
101 diff --git a/ayllu-shell/src/main.rs b/ayllu-shell/src/main.rs
102index b2450c8..2de4240 100644
103--- a/ayllu-shell/src/main.rs
104+++ b/ayllu-shell/src/main.rs
105 @@ -1,10 +1,9 @@
106- use std::os::unix::process::CommandExt;
107 use std::path::PathBuf;
108- use std::process::Command;
109
110 use clap::Parser;
111
112 mod config;
113+ mod error;
114 mod ui;
115
116 #[derive(Parser, Debug)]
117 @@ -28,14 +27,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
118 .iter()
119 .find(|identity| identity.username == username)
120 .unwrap();
121- let motd = config.shell.motd;
122- print!("{motd}");
123- match ui::read_option(identity.shell.as_ref().map(|path| path.as_path())) {
124- ui::MenuOption::Shell(path_buf) => {
125- let mut cmd = Command::new(path_buf.as_os_str());
126- let e = cmd.exec();
127- panic!("Failed to exec: {:?}", e);
128- }
129- ui::MenuOption::Exit => Ok(()),
130- }
131+ print!("{}", config.shell.motd);
132+ let menu = ui::Prompt {
133+ config: &config,
134+ identity,
135+ };
136+ menu.execute(None, None)?;
137+ Ok(())
138 }
139 diff --git a/ayllu-shell/src/ui.rs b/ayllu-shell/src/ui.rs
140index 0c41c47..bbc614d 100644
141--- a/ayllu-shell/src/ui.rs
142+++ b/ayllu-shell/src/ui.rs
143 @@ -3,39 +3,437 @@ use std::{
144 path::{Path, PathBuf},
145 };
146
147- use dialoguer::{theme::ColorfulTheme, Select};
148+ use ayllu_git::{Scanner, Sites, Wrapper};
149+ use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect, Select};
150
151- #[derive(Clone)]
152- pub enum MenuOption {
153- Shell(PathBuf),
154- Exit,
155+ use crate::{
156+ config::{Collection, Config, Identity},
157+ error::Error,
158+ };
159+
160+ macro_rules! default_menu {
161+ ($prompt:expr, $items:expr) => {
162+ Select::with_theme(&ColorfulTheme::default())
163+ .with_prompt($prompt)
164+ .default(0)
165+ .items($items)
166+ .interact_opt()
167+ .unwrap()
168+ };
169+ }
170+
171+ macro_rules! string_input {
172+ ($prompt:expr) => {
173+ Input::<String>::with_theme(&ColorfulTheme::default())
174+ .with_prompt($prompt)
175+ .interact_text()
176+ .unwrap()
177+ };
178+ }
179+
180+ macro_rules! string_input_default {
181+ ($prompt:expr, $default:expr) => {
182+ Input::<String>::with_theme(&ColorfulTheme::default())
183+ .with_prompt($prompt)
184+ .default($default.to_string())
185+ .interact_text()
186+ .unwrap()
187+ };
188 }
189
190- impl Display for MenuOption {
191- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192- match self {
193- MenuOption::Shell(path) => write!(f, "Enter Your Shell: {}", path.to_string_lossy()),
194- MenuOption::Exit => write!(f, "Disconnect"),
195+ fn maybe_string(input: &str) -> Option<String> {
196+ if input.is_empty() || input == "None" || input == "none" {
197+ None
198+ } else {
199+ Some(input.to_string())
200+ }
201+ }
202+
203+ mod helpers {
204+
205+ use super::*;
206+
207+ fn get_collection_path(identity: &Identity, collection: &Path) -> PathBuf {
208+ if collection.is_absolute() {
209+ collection.to_path_buf()
210+ } else {
211+ // Commands have to be run as the configured user validated by ayllu-keys
212+ let current_user = std::env::var("USER").expect("USER is not set");
213+ let user = nix::unistd::User::from_name(&current_user)
214+ .expect("Cannot find user")
215+ .expect("Unknown User");
216+ if identity.repositories_path.is_relative() {
217+ user.dir.join(&identity.repositories_path).join(collection)
218+ } else {
219+ identity.repositories_path.clone().join(collection)
220+ }
221+ }
222+ }
223+
224+ fn get_trash(identity: &Identity) -> PathBuf {
225+ get_collection_path(identity, Path::new(".trash"))
226+ }
227+
228+ pub struct Repository<'a> {
229+ pub collection: &'a Collection,
230+ pub name: String,
231+ pub description: Option<String>,
232+ pub path: PathBuf,
233+ }
234+
235+ pub fn repositories<'a>(
236+ identity: &Identity,
237+ collections: &'a [Collection],
238+ ) -> Result<Vec<Repository<'a>>, Error> {
239+ Ok(collections
240+ .iter()
241+ .try_fold(Vec::new(), |mut accm, collection| {
242+ let collection_path = get_collection_path(identity, &collection.path);
243+ let scanner: Scanner = TryInto::try_into(collection_path.as_path())?;
244+ let repositories =
245+ scanner
246+ .into_iter()
247+ .try_fold(Vec::new(), |mut repos, path| {
248+ let (_, name) = ayllu_git::collection_and_name(&path);
249+ let handle = Wrapper::new(&path)?;
250+ let description = handle.config()?.description.clone();
251+ repos.push(Repository {
252+ collection,
253+ name,
254+ description,
255+ path: path.to_path_buf(),
256+ });
257+ Ok::<_, ayllu_git::Error>(repos)
258+ })?;
259+ accm.extend(repositories);
260+ Ok::<Vec<Repository>, ayllu_git::Error>(accm)
261+ })?)
262+ }
263+
264+ pub fn open(
265+ identity: &Identity,
266+ collection: &Collection,
267+ name: &str,
268+ ) -> Result<Wrapper, Error> {
269+ let collection_path = get_collection_path(identity, &collection.path);
270+ let repository = Wrapper::new(&collection_path.join(name))?;
271+ Ok(repository)
272+ }
273+
274+ pub fn create(
275+ identity: &Identity,
276+ collection: &Collection,
277+ config: &ayllu_git::Config,
278+ name: &str,
279+ bare: bool,
280+ ) -> Result<Wrapper, Error> {
281+ let collection_path = get_collection_path(identity, &collection.path);
282+ let repository = Wrapper::create(&collection_path.join(Path::new(name)), bare)?;
283+ repository.apply_config(config)?;
284+ Ok(repository)
285+ }
286+
287+ pub fn r#move(
288+ identity: &Identity,
289+ src: &Collection,
290+ dst: &Collection,
291+ name: &str,
292+ ) -> Result<(), Error> {
293+ let src_path = get_collection_path(identity, src.path.as_path()).join(Path::new(name));
294+ let dst_path = get_collection_path(identity, dst.path.as_path()).join(Path::new(name));
295+ std::fs::rename(&src_path, &dst_path)?;
296+ println!("Moved {:?} --> {:?}", src_path, dst_path);
297+ Ok(())
298+ }
299+
300+ pub fn remove(identity: &Identity, src: &Collection, name: &str) -> Result<(), Error> {
301+ let src_path = get_collection_path(identity, &src.path.as_path()).join(Path::new(name));
302+ let trash_base = get_trash(identity);
303+ if !trash_base.exists() {
304+ std::fs::create_dir_all(&trash_base)?;
305 }
306+ let dst_path = trash_base.join(Path::new(name));
307+ std::fs::rename(&src_path, &dst_path)?;
308+ println!("Moved {:?} --> {:?}", src_path, dst_path);
309+ Ok(())
310 }
311 }
312
313- pub fn read_option(shell: Option<&Path>) -> MenuOption {
314- let mut options: Vec<MenuOption> = Vec::new();
315- if let Some(shell) = shell {
316- options.push(MenuOption::Shell(shell.to_path_buf()));
317+ mod menu {
318+
319+ use super::*;
320+
321+ // Main Menu Items
322+
323+ pub const INITIAL_ITEMS: &[Item] = &[Item::Create, Item::Browse, Item::Shell, Item::Exit];
324+ pub enum Item<'a> {
325+ /// Select a collection
326+ Collection(&'a Collection),
327+ /// Create a new repository
328+ Create,
329+ /// Edit a repository configuration
330+ Edit {
331+ collection: &'a Collection,
332+ name: String,
333+ },
334+ /// Delete a repository and all it's contents recursively (Destructive!)
335+ Delete {
336+ collection: &'a Collection,
337+ name: String,
338+ },
339+ Browse,
340+ /// Move a repository to another collection
341+ Move {
342+ collection: &'a Collection,
343+ name: String,
344+ },
345+ /// Select a repoisitory
346+ Repository {
347+ collection: &'a Collection,
348+ name: String,
349+ description: Option<String>,
350+ path: PathBuf,
351+ },
352+ /// Drop into a shell
353+ Shell,
354+ /// Exit the prompt
355+ Exit,
356 }
357- options.push(MenuOption::Exit);
358- println!();
359- let choice = Select::with_theme(&ColorfulTheme::default())
360- .with_prompt("What would you like to do?")
361- .default(0)
362- .items(options.as_slice())
363- .interact_opt()
364- .unwrap();
365- if let Some(choice) = choice {
366- options.get(choice).unwrap().clone()
367- } else {
368- read_option(shell)
369+
370+ impl Display for Item<'_> {
371+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372+ match self {
373+ Item::Create => write!(f, "Create a New Repository"),
374+ Item::Edit {
375+ collection: _,
376+ name: _,
377+ } => write!(f, "Edit"),
378+ Item::Delete {
379+ collection: _,
380+ name: _,
381+ } => write!(f, "Delete"),
382+ Item::Browse => write!(f, "Browse Repositories"),
383+ Item::Shell => write!(f, "Drop into a Shell"),
384+ Item::Exit => write!(f, "Exit"),
385+ Item::Repository {
386+ collection,
387+ name,
388+ description,
389+ path: _,
390+ } => {
391+ let description = if let Some(description) = description {
392+ format!(" ({description})")
393+ } else {
394+ String::default()
395+ };
396+ write!(f, "{}/{name}{description}", collection.name)
397+ }
398+ Item::Move {
399+ collection: _,
400+ name: _,
401+ } => {
402+ write!(f, "Move")
403+ }
404+ Item::Collection(collection) => write!(f, "{}", collection.name),
405+ }
406+ }
407+ }
408+
409+ pub(crate) fn select_collection(collections: &[Collection]) -> Option<&Collection> {
410+ let items: Vec<Item> = collections
411+ .iter()
412+ .map(|other| Item::Collection(other))
413+ .collect();
414+ match default_menu!("Select a Collection >", items.as_slice()) {
415+ Some(choice) => {
416+ let choice = items.get(choice).unwrap();
417+ match choice {
418+ Item::Collection(collection) => collections
419+ .iter()
420+ .find(|other| other.name == collection.name),
421+ _ => unreachable!(),
422+ }
423+ }
424+ None => None,
425+ }
426+ }
427+ }
428+
429+ pub struct Prompt<'a> {
430+ pub config: &'a Config,
431+ pub identity: &'a Identity,
432+ }
433+
434+ impl Prompt<'_> {
435+ pub fn execute(
436+ &self,
437+ item: Option<&menu::Item>,
438+ previous: Option<&menu::Item>,
439+ ) -> Result<(), Error> {
440+ match item {
441+ Some(menu::Item::Create) => {
442+ let collection = match menu::select_collection(self.config.collections.as_slice()) {
443+ Some(collection) => collection,
444+ None => return self.execute(None, None),
445+ };
446+ let name = string_input!("Choose a Descriptive Name");
447+ let mut configuration = ayllu_git::Config {
448+ homepage: maybe_string(&string_input_default!("Associated Homepage", "None")),
449+ description: Some(string_input_default!(
450+ "Provide a Friendly Description",
451+ "Just Another Codebase"
452+ )),
453+ ..Default::default()
454+ };
455+ let options = MultiSelect::new()
456+ .item_checked("Initialize Bare", true)
457+ .item_checked("Static Hosting", false)
458+ .interact()
459+ .unwrap();
460+ let bare = options.contains(&0);
461+ let static_hosting = options.contains(&1);
462+ if static_hosting {
463+ let hostname = string_input!("Provide a virtual hostname");
464+ let header = format!("Host: {hostname}");
465+ let content_path = string_input_default!(
466+ "Provide the path which content is served from",
467+ "content"
468+ );
469+ let branch =
470+ string_input_default!("Select the branch content is served from", "main");
471+ configuration.sites = Some(Sites {
472+ header: Some(header),
473+ content: Some(content_path),
474+ branch: Some(branch),
475+ });
476+ }
477+ helpers::create(self.identity, collection, &configuration, &name, bare)?;
478+ println!("Repository Created Successfully");
479+ self.execute(None, None)
480+ }
481+ Some(menu::Item::Edit { collection, name }) => {
482+ let repository = helpers::open(self.identity, collection, &name)?;
483+ let mut config = repository.config()?;
484+ config.description = maybe_string(&string_input_default!(
485+ "Description",
486+ config
487+ .description
488+ .as_ref()
489+ .cloned()
490+ .unwrap_or(String::from("None"))
491+ ));
492+ config.homepage = maybe_string(&string_input_default!(
493+ "Homepage",
494+ config
495+ .homepage
496+ .as_ref()
497+ .cloned()
498+ .unwrap_or(String::from("None"))
499+ ));
500+ let options = MultiSelect::new()
501+ .item_checked("Static Hosting", config.sites.is_some())
502+ .item_checked("Hidden", config.hidden.is_some_and(|hidden| hidden))
503+ .interact()
504+ .unwrap();
505+ let static_hosting = options.contains(&0);
506+ if options.contains(&1) {
507+ config.hidden = Some(true)
508+ } else {
509+ config.hidden = None
510+ };
511+ if static_hosting {
512+ let hostname = string_input!("Provide a virtual hostname");
513+ let header = format!("Host: {hostname}");
514+ let content_path = string_input_default!(
515+ "Provide the path which content is served from",
516+ "content"
517+ );
518+ let branch =
519+ string_input_default!("Select the branch content is served from", "main");
520+ config.sites = Some(Sites {
521+ header: Some(header),
522+ content: Some(content_path),
523+ branch: Some(branch),
524+ });
525+ }
526+ repository.apply_config(&config)?;
527+ self.execute(None, None)
528+ }
529+ Some(menu::Item::Delete { collection, name }) => {
530+ if Confirm::new()
531+ .with_prompt(format!(
532+ "Are you sure you want to move {}/{} to the trash",
533+ collection.name, name
534+ ))
535+ .interact()
536+ .unwrap()
537+ {
538+ helpers::remove(self.identity, collection, name)?;
539+ self.execute(None, None)
540+ } else {
541+ self.execute(None, None)
542+ }
543+ }
544+ Some(menu::Item::Browse) => {
545+ let repositories: Vec<menu::Item> =
546+ helpers::repositories(self.identity, self.config.collections.as_slice())?
547+ .iter()
548+ .map(|repo| menu::Item::Repository {
549+ collection: repo.collection,
550+ name: repo.name.clone(),
551+ description: repo.description.clone(),
552+ path: repo.path.clone(),
553+ })
554+ .collect();
555+ match default_menu!("Select a Repsoitory >", repositories.as_slice()) {
556+ Some(choice) => self.execute(repositories.get(choice), item),
557+ None => self.execute(None, None),
558+ }
559+ }
560+ Some(menu::Item::Move { collection, name }) => {
561+ match menu::select_collection(self.config.collections.as_slice()) {
562+ Some(other) => {
563+ helpers::r#move(self.identity, collection, &other, name)?;
564+ self.execute(None, None)
565+ }
566+ None => self.execute(None, None),
567+ }
568+ }
569+ Some(menu::Item::Shell) => todo!(),
570+ Some(menu::Item::Exit) => return Ok(()),
571+ Some(menu::Item::Repository {
572+ collection,
573+ name,
574+ path,
575+ description: _,
576+ }) => {
577+ let choices = &[
578+ menu::Item::Move {
579+ collection,
580+ name: name.clone(),
581+ },
582+ menu::Item::Edit {
583+ collection,
584+ name: name.clone(),
585+ },
586+ menu::Item::Delete {
587+ collection,
588+ name: name.clone(),
589+ },
590+ ];
591+ match default_menu!(
592+ format!("Modify {}/{name} ({path:?})", collection.name),
593+ choices
594+ ) {
595+ Some(choice) => self.execute(choices.get(choice), item),
596+ None => self.execute(previous, None),
597+ }
598+ }
599+ None => match default_menu!("What would you like to do?", menu::INITIAL_ITEMS) {
600+ Some(choice) => self.execute(menu::INITIAL_ITEMS.get(choice), None),
601+ None => Ok(()),
602+ },
603+ _ => unreachable!(),
604+ }
605 }
606 }
607 diff --git a/crates/git/src/config.rs b/crates/git/src/config.rs
608index 1bd8223..a76bae8 100644
609--- a/crates/git/src/config.rs
610+++ b/crates/git/src/config.rs
611 @@ -56,7 +56,7 @@ impl Remotes {
612 }
613
614 // ayllu specific configuration from a git repository
615- #[derive(Serialize, Clone, Debug)]
616+ #[derive(Serialize, Clone, Debug, Default)]
617 pub struct Config {
618 pub description: Option<String>,
619 pub homepage: Option<String>,
620 @@ -64,7 +64,7 @@ pub struct Config {
621 pub mail: Option<Vec<Email>>,
622 pub hidden: Option<bool>,
623 pub default_branch: Option<String>,
624- pub sites: Sites,
625+ pub sites: Option<Sites>,
626 /// an array of remotes where mirror = true, typically one when a
627 /// repository is cloned with with the mirror flag, i.e.
628 /// git clone --mirror ...
629 @@ -102,11 +102,15 @@ pub fn remotes(cfg: &GitConfig) -> Remotes {
630 )
631 }
632
633- pub fn string(cfg: &GitConfig, path: &str) -> Option<String> {
634+ fn has_option(cfg: &GitConfig, name: &str) -> bool {
635+ cfg.get_entry(name).is_ok()
636+ }
637+
638+ fn string(cfg: &GitConfig, path: &str) -> Option<String> {
639 cfg.get_string(path).map_or(None, |v| Some(v.to_string()))
640 }
641
642- pub fn strings(cfg: &GitConfig, path: &str) -> Result<Option<Vec<String>>, Error> {
643+ fn strings(cfg: &GitConfig, path: &str) -> Result<Option<Vec<String>>, Error> {
644 let mut strings: Vec<String> = Vec::new();
645 let entries = cfg.entries(Some(path))?;
646 entries.for_each(|entry| {
647 @@ -118,6 +122,87 @@ pub fn strings(cfg: &GitConfig, path: &str) -> Result<Option<Vec<String>>, Error
648 Ok(None)
649 }
650
651- pub fn bool(cfg: &GitConfig, path: &str) -> Option<bool> {
652+ fn bool(cfg: &GitConfig, path: &str) -> Option<bool> {
653 cfg.get_bool(path).ok()
654 }
655+
656+ macro_rules! optional_str {
657+ ($cfg:expr, $field:expr, $value:expr) => {
658+ if let Some(value) = $value.as_ref() {
659+ $cfg.set_str($field, value)
660+ } else {
661+ if $cfg.get_str($field).is_ok() {
662+ $cfg.remove($field)
663+ } else {
664+ Ok(())
665+ }
666+ }
667+ };
668+ }
669+
670+ pub(crate) fn read(git_config: &GitConfig) -> Result<Config, Error> {
671+ let chat = strings(git_config, "ayllu.chat")?;
672+ let chat = chat
673+ .map(|entries| {
674+ entries.iter().try_fold(Vec::new(), |mut accm, entry| {
675+ if entry.contains("xmpp://") {
676+ accm.push(ChatLink {
677+ url: entry.clone().replace("xmpp://", ""),
678+ kind: ChatKind::Xmpp,
679+ });
680+ Ok::<_, Error>(accm)
681+ } else if entry.contains("irc://") || entry.contains("ircs://") {
682+ accm.push(ChatLink {
683+ url: entry.clone().replace("irc://", "").replace("ircs://", ""),
684+ kind: ChatKind::Irc,
685+ });
686+ Ok::<_, Error>(accm)
687+ } else {
688+ todo!()
689+ }
690+ })
691+ })
692+ .transpose()?;
693+ let mail = strings(git_config, "ayllu.mail")?;
694+ let mail = mail.map(|entries| entries.iter().map(|entry| Email(entry.clone())).collect());
695+ let remotes = remotes(git_config);
696+ let is_mirror = remotes.has_mirror();
697+ Ok(Config {
698+ description: string(git_config, "ayllu.description"),
699+ homepage: string(git_config, "ayllu.homepage"),
700+ default_branch: string(git_config, "ayllu.default-branch"),
701+ chat,
702+ mail,
703+ hidden: bool(git_config, "ayllu.hidden"),
704+ sites: if has_option(git_config, "ayllu-sites") {
705+ Some(Sites {
706+ header: string(git_config, "ayllu-sites.header"),
707+ content: string(git_config, "ayllu-sites.content"),
708+ branch: string(git_config, "ayllu-sites.branch"),
709+ })
710+ } else {
711+ None
712+ },
713+ remotes: remotes.0,
714+ is_mirror,
715+ })
716+ }
717+
718+ pub(crate) fn apply_config(cfg: &mut GitConfig, other: &Config) -> Result<(), Error> {
719+ optional_str!(cfg, "ayllu.description", other.description)?;
720+ optional_str!(cfg, "ayllu.homepage", other.homepage)?;
721+ if other.hidden.is_some_and(|hidden| hidden) {
722+ cfg.set_bool("ayllu.hidden", true)?;
723+ } else {
724+ let _ = cfg.remove("ayllu.hidden");
725+ }
726+ if let Some(sites) = other.sites.as_ref() {
727+ cfg.set_bool("ayllu-sites.enabled", true)?;
728+ optional_str!(cfg, "ayllu-sites.header", sites.header)?;
729+ optional_str!(cfg, "ayllu-sites.content", sites.content)?;
730+ optional_str!(cfg, "ayllu-sites.branch", sites.branch)?;
731+ } else {
732+ let _ = cfg.remove("ayllu-sites");
733+ }
734+ Ok(())
735+ }
736 diff --git a/crates/git/src/lib.rs b/crates/git/src/lib.rs
737index 762ec2d..fced42f 100644
738--- a/crates/git/src/lib.rs
739+++ b/crates/git/src/lib.rs
740 @@ -1,4 +1,4 @@
741- pub use config::{ChatKind, ChatLink, Config};
742+ pub use config::{ChatKind, ChatLink, Config, Sites};
743 pub use error::{Error, ErrorKind};
744 pub use lite::{Blob, Branch, Commit, Kind, Note, Stats, Tag, TreeEntry};
745 pub use scanner::{collection, collection_and_name, contains, git_dir, name, Scanner};
746 diff --git a/crates/git/src/wrapper.rs b/crates/git/src/wrapper.rs
747index 269d0bb..1300a23 100644
748--- a/crates/git/src/wrapper.rs
749+++ b/crates/git/src/wrapper.rs
750 @@ -8,7 +8,7 @@ use tokio::process as async_process;
751
752 use git2::{
753 BlameOptions, BranchType, DiffFormat, ErrorCode as GitErrorCode, ObjectType, Oid,
754- ReferenceType, Repository, Sort, Tree, TreeEntry,
755+ ReferenceType, Repository, RepositoryInitOptions, Sort, Tree, TreeEntry,
756 };
757 use rand::{distr::Alphanumeric, Rng};
758 use tracing::log;
759 @@ -42,6 +42,16 @@ impl Wrapper {
760 })
761 }
762
763+ /// Create a new git repository at the given path
764+ pub fn create(path: &Path, bare: bool) -> Result<Self, Error> {
765+ let repository =
766+ Repository::init_opts(path, RepositoryInitOptions::new().bare(bare).mkdir(true))?;
767+ Ok(Wrapper {
768+ path: path.to_path_buf(),
769+ repository: Box::new(repository),
770+ })
771+ }
772+
773 pub fn path(&self) -> &Path {
774 self.path.as_path()
775 }
776 @@ -468,56 +478,13 @@ impl Wrapper {
777 }
778
779 pub fn config(&self) -> Result<config::Config, Error> {
780- let git_config = &self.repository.config()?;
781- let chat = config::strings(git_config, "ayllu.chat")?;
782- let chat = chat
783- .map(|entries| {
784- entries.iter().try_fold(Vec::new(), |mut accm, entry| {
785- if entry.contains("xmpp://") {
786- accm.push(config::ChatLink {
787- url: entry.clone().replace("xmpp://", ""),
788- kind: config::ChatKind::Xmpp,
789- });
790- Ok(accm)
791- } else if entry.contains("irc://") || entry.contains("ircs://") {
792- accm.push(config::ChatLink {
793- url: entry.clone().replace("irc://", "").replace("ircs://", ""),
794- kind: config::ChatKind::Irc,
795- });
796- Ok(accm)
797- } else {
798- Err(Error::kind(ErrorKind::ConfigError(format!(
799- "{} is not a valid discussion URL",
800- entry.clone()
801- ))))
802- }
803- })
804- })
805- .transpose()?;
806- let mail = config::strings(git_config, "ayllu.mail")?;
807- let mail = mail.map(|entries| {
808- entries
809- .iter()
810- .map(|entry| config::Email(entry.clone()))
811- .collect()
812- });
813- let remotes = config::remotes(git_config);
814- let is_mirror = remotes.has_mirror();
815- Ok(config::Config {
816- description: config::string(git_config, "ayllu.description"),
817- homepage: config::string(git_config, "ayllu.homepage"),
818- default_branch: config::string(git_config, "ayllu.default-branch"),
819- chat,
820- mail,
821- hidden: config::bool(git_config, "ayllu.hidden"),
822- sites: config::Sites {
823- header: config::string(git_config, "ayllu-sites.header"),
824- content: config::string(git_config, "ayllu-sites.content"),
825- branch: config::string(git_config, "ayllu-sites.branch"),
826- },
827- remotes: remotes.0,
828- is_mirror,
829- })
830+ crate::config::read(&self.repository.config()?)
831+ }
832+
833+ pub fn apply_config(&self, other: &config::Config) -> Result<(), Error> {
834+ let mut config = self.repository.config()?;
835+ crate::config::apply_config(&mut config, other)?;
836+ Ok(())
837 }
838
839 pub fn last_modified(&self) -> Result<Option<u64>, Error> {