don't
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(knot): re-work hook handling and add shell completions

Git hooks are now run explicitly using the 'hook' subcommand and the
hook name instead of using symlinks and sniffing the invoked executable
name. Hooks are now installed as scripts in a temporary directory at
start-up, unless `--hooks` has been specified.

Signed-off-by: tjh <x@tjh.dev>

tjh 1bf4f3a9 6226416b

+155 -215
+10
Cargo.lock
··· 428 428 ] 429 429 430 430 [[package]] 431 + name = "clap_complete" 432 + version = "4.5.65" 433 + source = "registry+https://github.com/rust-lang/crates.io-index" 434 + checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d" 435 + dependencies = [ 436 + "clap", 437 + ] 438 + 439 + [[package]] 431 440 name = "clap_derive" 432 441 version = "4.5.55" 433 442 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2736 2727 "axum-extra", 2737 2728 "bytes", 2738 2729 "clap", 2730 + "clap_complete", 2739 2731 "dashmap", 2740 2732 "data-encoding", 2741 2733 "futures-util",
+1 -1
contrib/gordian-knot.service
··· 7 7 [Service] 8 8 Environment=KNOT_REPO_BASE=/var/lib/tangled 9 9 WorkingDirectory=/var/lib/tangled 10 - ExecStart=/usr/bin/knot 10 + ExecStart=/usr/bin/knot serve 11 11 Restart=always 12 12 User=git 13 13 Group=git
+2 -1
crates/knot/Cargo.toml
··· 40 40 rustc-hash = "2.1.1" 41 41 time.workspace = true 42 42 sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "time", "json", "macros", "derive"] } 43 + tempfile = "3.24.0" 43 44 tokio = { version = "1.47.1", features = ["io-util", "macros", "net", "process", "signal", "rt-multi-thread"] } 44 45 tokio-rayon = "2.1.0" 45 46 tokio-stream = { version = "0.1.17", features = ["time"] } ··· 51 50 tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } 52 51 dashmap = "6.1.0" 53 52 mock-pds = { version = "0.0.0", path = "../mock-pds" } 53 + clap_complete = "4.5.65" 54 54 55 55 [dev-dependencies] 56 56 http-body-util = "0.1.3" 57 57 multibase = "0.9.1" 58 - tempfile = "3.24.0" 59 58 60 59 [target.'cfg(not(target_env = "msvc"))'.dependencies] 61 60 tikv-jemallocator = { version = "0.6.1", optional = true }
+86 -18
crates/knot/src/cli.rs
··· 1 1 use atproto::did::OwnedDid; 2 - use clap::{ArgAction, Parser, ValueEnum, ValueHint}; 2 + use clap::{ArgAction, Args, CommandFactory, Parser, Subcommand, ValueEnum, ValueHint}; 3 + use clap_complete::Shell; 4 + use core::fmt; 3 5 use gix::bstr::BString; 4 6 use identity::HttpClient; 5 7 use knot::model::config::{DEFAULT_READMES, KnotConfiguration, RepoCacheConfig}; 6 8 use std::{env, path::PathBuf, time::Duration}; 7 9 use url::Url; 8 10 9 - pub fn parse() -> Arguments { 10 - let mut arguments = Arguments::parse(); 11 + pub fn parse() -> KnotCommand { 12 + match Arguments::parse().command { 13 + KnotCommand::Generate(arguments) => { 14 + let mut command = Arguments::command(); 15 + let name = command.get_name().to_string(); 16 + clap_complete::generate(arguments.shell, &mut command, name, &mut std::io::stdout()); 17 + std::process::exit(0); 18 + } 19 + KnotCommand::Serve(mut arguments) => { 20 + if let Some("") = arguments.archive_bz2_command.as_deref() { 21 + arguments.archive_bz2_command = None; 22 + } 11 23 12 - if let Some("") = arguments.archive_bz2_command.as_deref() { 13 - arguments.archive_bz2_command = None; 24 + if let Some("") = arguments.archive_xz_command.as_deref() { 25 + arguments.archive_xz_command = None; 26 + } 27 + 28 + KnotCommand::Serve(arguments) 29 + } 30 + hook @ KnotCommand::Hook(_) => hook, 14 31 } 15 - 16 - if let Some("") = arguments.archive_xz_command.as_deref() { 17 - arguments.archive_xz_command = None; 18 - } 19 - 20 - arguments 21 32 } 22 33 23 - #[derive(Clone, Debug, Parser)] 34 + #[derive(Debug, Parser)] 24 35 #[command(about, author, version)] 25 36 pub struct Arguments { 37 + #[clap(subcommand)] 38 + command: KnotCommand, 39 + } 40 + 41 + #[derive(Debug, Subcommand, Clone)] 42 + pub enum KnotCommand { 43 + Generate(GenerateArguments), 44 + Serve(ServeArguments), 45 + Hook(HookArguments), 46 + } 47 + 48 + /// Generate shell completions. 49 + #[derive(Clone, Debug, Args)] 50 + pub struct GenerateArguments { 51 + shell: Shell, 52 + } 53 + 54 + /// Serve the tangled knot. 55 + #[derive(Clone, Debug, Args)] 56 + pub struct ServeArguments { 26 57 /// FQDN of the knot. 27 58 #[arg(long, short, value_hint = ValueHint::Hostname, env = "KNOT_NAME")] 28 59 #[cfg_attr(debug_assertions, arg(default_value = "localhost:5555"))] ··· 68 37 #[arg(default_value = default_repository_base().into_os_string())] 69 38 pub repos: PathBuf, 70 39 71 - /// Path to install knot-level git hooks. 40 + /// Path to knot-level git hooks. 72 41 #[arg(long, short = 'H', value_hint = ValueHint::DirPath, env = "KNOT_HOOKS_PATH")] 73 - #[arg(default_value = default_repository_base().join("hooks").into_os_string())] 74 - pub hooks: PathBuf, 42 + pub hooks: Option<PathBuf>, 75 43 76 44 /// Path to knot-level git config. 77 45 #[arg(long, value_hint = ValueHint::FilePath, env = "KNOT_GIT_CONFIG_PATH")] ··· 144 114 Some(full_path.trim().to_string()) 145 115 } 146 116 147 - impl Arguments { 117 + impl ServeArguments { 148 118 pub fn to_knot_config(&self) -> Result<KnotConfiguration, Error> { 149 119 let Self { 150 120 name, 151 121 owner, 152 122 repos: repo_path, 153 - hooks: hook_path, 123 + hooks: _, 154 124 git_config, 155 125 bind: _, 156 126 db: _, ··· 173 143 owner, 174 144 instance, 175 145 repo_path, 176 - hook_path, 177 146 git_config, 178 147 readmes: DEFAULT_READMES 179 148 .iter() ··· 214 185 215 186 fn default_jetstream_instances() -> String { 216 187 jetstream::PUBLIC_JETSTREAM_INSTANCES.join(",") 188 + } 189 + 190 + #[derive(Clone, Debug, Args)] 191 + pub struct HookArguments { 192 + pub hook: HookName, 193 + } 194 + 195 + #[derive(Clone, Copy, Debug, ValueEnum)] 196 + #[clap(rename_all = "kebab-case")] 197 + pub enum HookName { 198 + PreReceive, 199 + PostReceive, 200 + PostUpdate, 201 + } 202 + 203 + impl fmt::Display for HookName { 204 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 205 + f.write_str(match self { 206 + Self::PreReceive => "pre-receive", 207 + Self::PostReceive => "post-receive", 208 + Self::PostUpdate => "post-update", 209 + }) 210 + } 211 + } 212 + 213 + impl AsRef<std::path::Path> for HookName { 214 + fn as_ref(&self) -> &std::path::Path { 215 + std::path::Path::new(match self { 216 + Self::PreReceive => "pre-receive", 217 + Self::PostReceive => "post-receive", 218 + Self::PostUpdate => "post-update", 219 + }) 220 + } 221 + } 222 + 223 + impl HookName { 224 + pub fn iter_variants() -> impl Iterator<Item = HookName> { 225 + [Self::PreReceive, Self::PostReceive, Self::PostUpdate].into_iter() 226 + } 217 227 }
+21 -45
crates/knot/src/hooks.rs
··· 1 - use core::fmt; 2 1 use std::{ 3 2 collections::HashMap, 4 - env, fs, 3 + env, 4 + fs::{self, Permissions}, 5 5 io::{self, Write}, 6 + os::unix::fs::PermissionsExt, 6 7 path::Path, 7 8 }; 8 9 9 10 use atproto::did::OwnedDid; 10 11 use axum::http::{HeaderMap, HeaderName, HeaderValue, header::InvalidHeaderName}; 11 12 use bytes::Bytes; 12 - use knot::private::{self, Hook}; 13 + use knot::private; 13 14 use url::Url; 14 15 16 + use crate::cli::HookName; 17 + 15 18 /// Setup the global hooks directory at `path`. 16 - /// 17 - /// Currently, this just creates symlinks to the currently running executable for the 18 - /// hook names returned by [`Hook::iter_names()`]. 19 - /// 20 19 pub fn setup_global_hooks<P: AsRef<Path>>(path: P) -> io::Result<()> { 21 - let current_exe = env::current_exe().inspect_err(|error| tracing::error!(?error))?; 20 + let executable = env::current_exe() 21 + .map(|path| path.to_str().map(ToOwned::to_owned)) 22 + .expect("Current executable must be defined") 23 + .expect("Current executable must be valid utf8"); 24 + 22 25 let _ = fs::create_dir_all(&path); 23 - for hook_name in Hook::iter_names() { 26 + for hook_name in HookName::iter_variants() { 24 27 let hook_path = path.as_ref().join(hook_name); 25 - update_symlink(&hook_path, &current_exe)?; 26 - tracing::info!(?current_exe, ?hook_path, "git hook installed"); 28 + let script = format!( 29 + "#!/usr/bin/sh\n# This file is generated by gordian-knot. Do not modify.\n{executable} hook {hook_name}\n" 30 + ); 31 + std::fs::write(&hook_path, script)?; 32 + 33 + let permissions = Permissions::from_mode(0o755); 34 + std::fs::set_permissions(&hook_path, permissions)?; 35 + tracing::info!(?executable, ?hook_path, "git hook installed"); 27 36 } 28 37 Ok(()) 29 38 } 30 39 31 - /// Creates or updates a symlink pointing to the specified target. 32 - /// 33 - /// # Errors 34 - /// 35 - /// Will return an error in the following situations, but is not limited to just these 36 - /// cases: 37 - /// 38 - /// * If the path `symlink` already exists, but is not a symlink. 39 - /// 40 - fn update_symlink<P: AsRef<Path> + fmt::Debug, Q: AsRef<Path>>( 41 - symlink: P, 42 - target: Q, 43 - ) -> io::Result<fs::Metadata> { 44 - match fs::symlink_metadata(&symlink) { 45 - Ok(attr) if attr.is_symlink() => { 46 - fs::remove_file(&symlink)?; 47 - } 48 - Ok(_) => Err(io::Error::new( 49 - io::ErrorKind::AlreadyExists, 50 - format!("{symlink:?} already exists and is not a symlink"), 51 - ))?, 52 - Err(error) if matches!(error.kind(), io::ErrorKind::NotFound) => {} 53 - Err(error) => Err(error)?, 54 - } 55 - 56 - std::os::unix::fs::symlink(target, &symlink)?; 57 - fs::metadata(symlink) 58 - } 59 - 60 40 #[tracing::instrument] 61 - pub async fn run_hook(hook: &Hook) -> anyhow::Result<()> { 62 - if matches!(hook, Hook::ReferenceTransaction) { 63 - return Ok(()); 64 - } 65 - 41 + pub async fn run_hook(hook: HookName) -> anyhow::Result<()> { 66 42 let mut environment_vars: HashMap<_, _> = env::vars() 67 43 .filter(|(key, _)| !key.trim().is_empty()) 68 44 .collect();
+20 -27
crates/knot/src/main.rs
··· 51 51 .build() 52 52 .expect("Failed to build runtime"); 53 53 54 - if let Ok(hook) = ().try_into() { 55 - match runtime.block_on(hooks::run_hook(&hook)) { 56 - Ok(()) => std::process::exit(0), 57 - Err(error) => { 58 - tracing::error!(?error, "failed to run hook"); 59 - std::process::exit(1); 60 - } 61 - } 54 + match cli::parse() { 55 + cli::KnotCommand::Generate(_) => unreachable!("Handled by cli module"), 56 + cli::KnotCommand::Serve(arguments) => runtime.block_on(knot_main(arguments)), 57 + cli::KnotCommand::Hook(arguments) => runtime.block_on(hooks::run_hook(arguments.hook)), 62 58 } 63 - 64 - let arguments = cli::parse(); 65 - tracing::debug!(?arguments); 66 - runtime.block_on(knot_main(arguments)) 67 59 } 68 60 69 - pub async fn knot_main(arguments: cli::Arguments) -> anyhow::Result<()> { 61 + pub async fn knot_main(arguments: cli::ServeArguments) -> anyhow::Result<()> { 70 62 unsafe { env::set_var("GIT_CONFIG_GLOBAL", &arguments.git_config) }; 71 63 72 - hooks::setup_global_hooks(&arguments.hooks)?; 73 - assert!(git_config_global_set("core.hooksPath", &arguments.hooks)?); 74 - assert!(git_config_global_set( 75 - "receive.advertisePushOptions", 76 - if arguments.require_signed_push { 77 - "true" 78 - } else { 79 - "false" 80 - } 81 - )?); 64 + let tempdir = tempfile::TempDir::with_prefix("gordian-knot-")?; 65 + let hooks_path = if let Some(path) = &arguments.hooks { 66 + // @TODO Verify hooks exist in the specified path. 67 + tracing::warn!(?path, "assuming existence of hooks at path"); 68 + path.to_path_buf() 69 + } else { 70 + let path = tempdir.path().join("hooks"); 71 + hooks::setup_global_hooks(&path)?; 72 + path 73 + }; 82 74 75 + assert!(git_config_global("core.hooksPath", &hooks_path)?); 76 + assert!(git_config_global("receive.advertisePushOptions", "true")?); 83 77 if let Some(command) = &arguments.archive_bz2_command { 84 - assert!(git_config_global_set("tar.tar.bz2.command", command)?); 78 + assert!(git_config_global("tar.tar.bz2.command", command)?); 85 79 } 86 - 87 80 if let Some(command) = &arguments.archive_xz_command { 88 - assert!(git_config_global_set("tar.tar.xz.command", command)?); 81 + assert!(git_config_global("tar.tar.xz.command", command)?); 89 82 } 90 83 91 84 let database = { ··· 224 231 Ok(()) 225 232 } 226 233 227 - fn git_config_global_set<K, V>(key: K, value: V) -> std::io::Result<bool> 234 + fn git_config_global<K, V>(key: K, value: V) -> std::io::Result<bool> 228 235 where 229 236 K: AsRef<OsStr>, 230 237 V: AsRef<OsStr>,
-8
crates/knot/src/model/config.rs
··· 30 30 pub owner: OwnedDid, 31 31 pub instance: OwnedDid, 32 32 pub repo_path: PathBuf, 33 - pub hook_path: PathBuf, 34 33 pub git_config: PathBuf, 35 34 pub readmes: FxHashSet<BString>, 36 35 pub repo_cache: RepoCacheConfig, ··· 54 55 owner, 55 56 instance, 56 57 repo_path: base.to_path_buf(), 57 - hook_path: base.join("hooks").to_path_buf(), 58 58 git_config: base.join("git_config").to_path_buf(), 59 59 readmes: DEFAULT_READMES 60 60 .iter() ··· 85 87 #[inline] 86 88 pub fn repository_base(&self) -> &Path { 87 89 self.repo_path.as_path() 88 - } 89 - 90 - /// Path to the knot's global git hooks directory. 91 - #[inline] 92 - pub fn hook_path(&self) -> &Path { 93 - self.hook_path.as_path() 94 90 } 95 91 96 92 #[inline]
+15 -115
crates/knot/src/private.rs
··· 14 14 use time::OffsetDateTime; 15 15 use tokio_rayon::AsyncThreadPool as _; 16 16 17 + use crate::{ 18 + model::{ 19 + Knot, errors, 20 + knot_state::Event, 21 + repository::{RepositoryStatsExt as _, TangledRepository}, 22 + }, 23 + public::xrpc::XrpcError, 24 + types::{push_certificate::PushCertificate, repository_key::RepositoryKey}, 25 + }; 26 + 17 27 /// Environment variable containing one or more whitespace separated URLs for the internal API. 18 28 /// 19 29 /// By default, knot will serve the internal API on all the addresses resolved from `localhost` ··· 47 37 /// Prefix to add when converting an environment variable from the hook to a HTTP header. 48 38 pub const ENV_HEADER_PREFIX: &str = "X-Gordian"; 49 39 50 - use crate::{ 51 - model::{ 52 - Knot, errors, 53 - knot_state::Event, 54 - repository::{RepositoryStatsExt as _, TangledRepository}, 55 - }, 56 - public::xrpc::XrpcError, 57 - types::{push_certificate::PushCertificate, repository_key::RepositoryKey}, 58 - }; 59 - 60 40 /// Build a new router for the internal API. 61 41 #[rustfmt::skip] 62 42 pub fn router() -> axum::Router<Knot> { ··· 59 59 } 60 60 61 61 /// Hooks handled by knot. 62 - #[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] 62 + #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] 63 63 #[serde(rename_all = "kebab-case")] 64 64 pub enum Hook { 65 65 PostReceive, 66 66 PostUpdate, 67 67 PreReceive, 68 - ReferenceTransaction, 69 68 } 70 69 71 70 impl Hook { 72 - /// Get the hook name as a `'static str`. 73 - /// 74 - /// # Example 75 - /// 76 - /// ```rust 77 - /// # use knot::private::Hook; 78 - /// assert_eq!(Hook::PostReceive.as_str(), "post-receive"); 79 - /// assert_eq!(Hook::PostUpdate.as_str(), "post-update"); 80 - /// assert_eq!(Hook::PreReceive.as_str(), "pre-receive"); 81 - /// assert_eq!(Hook::ReferenceTransaction.as_str(), "reference-transaction"); 82 - /// ``` 83 - /// 71 + /// Get the hook name as a `&'static str`. 84 72 pub fn as_str(&self) -> &'static str { 85 73 match self { 86 74 Self::PostReceive => "post-receive", 87 75 Self::PostUpdate => "post-update", 88 76 Self::PreReceive => "pre-receive", 89 - Self::ReferenceTransaction => "reference-transaction", 90 77 } 91 - } 92 - 93 - /// Iterate over all the supported hooks. 94 - /// 95 - /// # Example 96 - /// 97 - /// ```rust 98 - /// # use knot::private::Hook; 99 - /// let mut hooks = Hook::iter(); 100 - /// assert_eq!(hooks.next(), Some(Hook::PostReceive)); 101 - /// assert_eq!(hooks.next(), Some(Hook::PostUpdate)); 102 - /// assert_eq!(hooks.next(), Some(Hook::PreReceive)); 103 - /// assert_eq!(hooks.next(), Some(Hook::ReferenceTransaction)); 104 - /// assert_eq!(hooks.next(), None); 105 - /// ``` 106 - /// 107 - pub fn iter() -> impl Iterator<Item = Self> { 108 - [ 109 - Self::PostReceive, 110 - Self::PostUpdate, 111 - Self::PreReceive, 112 - Self::ReferenceTransaction, 113 - ] 114 - .into_iter() 115 - } 116 - 117 - /// Iterate over all the supported hook names. 118 - /// 119 - /// # Example 120 - /// 121 - /// ```rust 122 - /// # use knot::private::Hook; 123 - /// let mut names = Hook::iter_names(); 124 - /// assert_eq!(names.next(), Some("post-receive")); 125 - /// assert_eq!(names.next(), Some("post-update")); 126 - /// assert_eq!(names.next(), Some("pre-receive")); 127 - /// assert_eq!(names.next(), Some("reference-transaction")); 128 - /// assert_eq!(names.next(), None); 129 - /// ``` 130 - /// 131 - pub fn iter_names() -> impl Iterator<Item = &'static str> { 132 - Self::iter().map(|hook| hook.as_str()) 133 78 } 134 79 } 135 80 136 81 impl fmt::Display for Hook { 137 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 138 - write!(f, "{}", self.as_str()) 139 - } 140 - } 141 - 142 - #[derive(Debug, thiserror::Error)] 143 - #[error("Hook name not recognised")] 144 - pub struct UnknownHook; 145 - 146 - impl std::str::FromStr for Hook { 147 - type Err = UnknownHook; 148 - fn from_str(s: &str) -> Result<Self, Self::Err> { 149 - match s { 150 - "post-receive" => Ok(Self::PostReceive), 151 - "post-update" => Ok(Self::PostUpdate), 152 - "pre-receive" => Ok(Self::PreReceive), 153 - "reference-transaction" => Ok(Self::ReferenceTransaction), 154 - _ => Err(UnknownHook), 155 - } 156 - } 157 - } 158 - 159 - impl TryFrom<&std::ffi::OsStr> for Hook { 160 - type Error = UnknownHook; 161 - fn try_from(value: &std::ffi::OsStr) -> Result<Self, Self::Error> { 162 - match value.as_encoded_bytes() { 163 - b"post-receive" => Ok(Self::PostReceive), 164 - b"post-update" => Ok(Self::PostUpdate), 165 - b"pre-receive" => Ok(Self::PreReceive), 166 - b"reference-transaction" => Ok(Self::ReferenceTransaction), 167 - _ => Err(UnknownHook), 168 - } 169 - } 170 - } 171 - 172 - impl TryFrom<()> for Hook { 173 - type Error = UnknownHook; 174 - /// Extract the hook from the invoked executable name. 175 - fn try_from(_: ()) -> Result<Self, Self::Error> { 176 - fn executable_name() -> Option<std::ffi::OsString> { 177 - use std::path::Path; 178 - std::env::args_os() 179 - .next() 180 - .and_then(|arg| Path::new(&arg).file_name().map(ToOwned::to_owned)) 181 - } 182 - 183 - Hook::try_from(executable_name().ok_or(UnknownHook)?.as_os_str()) 82 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 83 + f.write_str(self.as_str()) 184 84 } 185 85 } 186 86 ··· 125 225 } 126 226 } 127 227 128 - #[tracing::instrument(skip(knot, owner, rkey, headers))] 228 + #[tracing::instrument(skip(knot, headers))] 129 229 async fn hook_pre_receive( 130 230 State(knot): State<Knot>, 131 231 Path(RepositoryKey { owner, rkey }): Path<RepositoryKey>,