1use anyhow::Result;
2use axum::{
3 extract::{
4 ws::{Message, WebSocket, WebSocketUpgrade},
5 State,
6 },
7 http::header,
8 response::{Html, IntoResponse, Response},
9 routing::get,
10 Router,
11};
12use colored::Colorize;
13use std::collections::{BTreeMap, HashSet};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::SystemTime;
17
18use githops_core::config::{
19 Command, CommandCache, CommandEntry, Config, DefinitionEntry, GlobalCache, HookConfig,
20 GitInclude, IncludeEntry, IncludeRef, IncludeType, LocalInclude, RefEntry,
21 RemoteInclude, resolve_include_entry, CONFIG_FILE,
22};
23use githops_core::git::hooks_dir;
24use githops_core::hooks::ALL_HOOKS;
25
26pub static INDEX_HTML: &str = include_str!("../ui/dist/index.html");
31pub static APP_JS: &str = include_str!("../ui/dist/assets/app.js");
32pub static APP_CSS: &str = include_str!("../ui/dist/assets/app.css");
33
34pub fn run(open: bool) -> Result<()> {
39 tokio::runtime::Builder::new_multi_thread()
40 .enable_all()
41 .build()?
42 .block_on(async_run(open))
43}
44
45async fn async_run(open: bool) -> Result<()> {
46 let config_path = Arc::new(std::env::current_dir()?.join(CONFIG_FILE));
47
48 let listener = match tokio::net::TcpListener::bind("127.0.0.1:7890").await {
49 Ok(l) => l,
50 Err(_) => tokio::net::TcpListener::bind("127.0.0.1:0").await?,
51 };
52 let port = listener.local_addr()?.port();
53 let url = format!("http://127.0.0.1:{}", port);
54
55 println!("{} {}", "githops graph:".green().bold(), url.cyan().bold());
56 println!(
57 " {}",
58 "Press Ctrl+C to stop. Changes are saved to githops.yaml immediately.".dimmed()
59 );
60
61 if open {
62 open_in_browser(&url);
63 } else {
64 println!(
65 " {} Use {} to open in browser.",
66 "tip:".dimmed(),
67 "githops graph --open".cyan()
68 );
69 }
70 println!();
71
72 let app = Router::new()
73 .route("/", get(serve_html))
74 .route("/docs", get(serve_html))
75 .route("/docs/*path", get(serve_html))
76 .route("/assets/app.js", get(serve_js))
77 .route("/assets/app.css", get(serve_css))
78 .route("/ws", get(ws_handler))
79 .with_state(config_path);
80
81 axum::serve(listener, app).await?;
82 Ok(())
83}
84
85async fn serve_html() -> Html<&'static str> {
90 Html(INDEX_HTML)
91}
92
93async fn serve_js() -> Response {
94 (
95 [(
96 header::CONTENT_TYPE,
97 "application/javascript; charset=utf-8",
98 )],
99 APP_JS,
100 )
101 .into_response()
102}
103
104async fn serve_css() -> Response {
105 (
106 [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
107 APP_CSS,
108 )
109 .into_response()
110}
111
112async fn ws_handler(
117 ws: WebSocketUpgrade,
118 State(config_path): State<Arc<PathBuf>>,
119) -> Response {
120 ws.on_upgrade(move |socket| ws_loop(socket, config_path))
121}
122
123fn config_mtime(path: &Path) -> Option<SystemTime> {
124 path.metadata().ok()?.modified().ok()
125}
126
127async fn ws_loop(mut socket: WebSocket, config_path: Arc<PathBuf>) {
128 if let Ok(json) = api_state(&config_path) {
129 let event = format!(r#"{{"method":"state","params":{}}}"#, json);
130 if socket.send(Message::Text(event)).await.is_err() {
131 return;
132 }
133 }
134
135 let mut last_mtime = config_mtime(&config_path);
136 let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
137
138 loop {
139 tokio::select! {
140 _ = interval.tick() => {
141 let mtime = config_mtime(&config_path);
142 if mtime != last_mtime {
143 last_mtime = mtime;
144 if let Ok(json) = api_state(&config_path) {
145 let event = format!(r#"{{"method":"state","params":{}}}"#, json);
146 if socket.send(Message::Text(event)).await.is_err() {
147 return;
148 }
149 }
150 }
151 }
152 msg = socket.recv() => {
153 match msg {
154 Some(Ok(Message::Text(text))) => {
155 let (response, push_state) = dispatch_ws(&text, &config_path);
156 if socket.send(Message::Text(response)).await.is_err() {
157 return;
158 }
159 if push_state {
160 last_mtime = config_mtime(&config_path);
161 if let Ok(json) = api_state(&config_path) {
162 let event = format!(r#"{{"method":"state","params":{}}}"#, json);
163 if socket.send(Message::Text(event)).await.is_err() {
164 return;
165 }
166 }
167 }
168 }
169 Some(Ok(Message::Close(_))) | None => return,
170 _ => {}
171 }
172 }
173 }
174 }
175}
176
177fn dispatch_ws(text: &str, config_path: &Path) -> (String, bool) {
178 #[derive(serde::Deserialize)]
179 struct WsReq {
180 id: u64,
181 method: String,
182 #[serde(default)]
183 params: serde_json::Value,
184 }
185
186 let req = match serde_json::from_str::<WsReq>(text) {
187 Ok(r) => r,
188 Err(e) => {
189 return (
190 format!(r#"{{"id":0,"error":{{"message":"parse error: {}"}}}}"#, e),
191 false,
192 );
193 }
194 };
195
196 let id = req.id;
197 match handle_ws_request(&req.method, req.params, config_path) {
198 Ok(result) => (
199 serde_json::json!({"id": id, "result": result}).to_string(),
200 true,
201 ),
202 Err(e) => (
203 serde_json::json!({"id": id, "error": {"message": e.to_string()}}).to_string(),
204 false,
205 ),
206 }
207}
208
209fn handle_ws_request(
210 method: &str,
211 params: serde_json::Value,
212 config_path: &Path,
213) -> Result<serde_json::Value> {
214 match method {
215 "hook.update" | "hook.remove" | "command.update" | "definition.update"
216 | "definition.delete" => {
217 let action = match method {
218 "hook.update" => "update",
219 "hook.remove" => "remove",
220 "command.update" => "update-command",
221 "definition.update" => "update-definition",
222 "definition.delete" => "delete-definition",
223 _ => unreachable!(),
224 };
225 let mut obj = match params {
226 serde_json::Value::Object(m) => m,
227 _ => serde_json::Map::new(),
228 };
229 obj.insert(
230 "action".into(),
231 serde_json::Value::String(action.to_string()),
232 );
233 let body = serde_json::to_vec(&serde_json::Value::Object(obj))?;
234 api_update(&body, config_path)?;
235 Ok(serde_json::json!({ "ok": true }))
236 }
237 "include.update" => {
238 #[derive(serde::Deserialize)]
239 struct IncludeUpdateParams {
240 #[serde(default, rename = "oldRef")]
241 old_ref: String,
242 #[serde(rename = "ref")]
243 ref_name: String,
244 source: String, #[serde(default)]
247 path: String,
248 #[serde(default)]
250 url: String,
251 #[serde(default)]
253 rev: String,
254 #[serde(default)]
255 file: String,
256 #[serde(rename = "type", default)]
258 file_type: String,
259 }
260 let p: IncludeUpdateParams = serde_json::from_value(params)?;
261 let mut config = if config_path.exists() {
262 Config::load(config_path)?
263 } else {
264 Config::default()
265 };
266 let ft = match p.file_type.as_str() {
267 "json" => IncludeType::Json,
268 "toml" => IncludeType::Toml,
269 _ => IncludeType::Yaml,
270 };
271 let new_entry = match p.source.as_str() {
272 "remote" => IncludeEntry::Remote(RemoteInclude {
273 url: p.url,
274 file_type: ft,
275 ref_name: p.ref_name.clone(),
276 }),
277 "git" => IncludeEntry::Git(GitInclude {
278 url: p.url,
279 rev: p.rev,
280 file: p.file,
281 file_type: ft,
282 ref_name: p.ref_name.clone(),
283 }),
284 _ => IncludeEntry::Local(LocalInclude {
285 path: p.path,
286 file_type: ft,
287 ref_name: p.ref_name.clone(),
288 }),
289 };
290 if !p.old_ref.is_empty() {
291 if let Some(pos) = config.include.iter().position(|e| e.ref_name() == p.old_ref) {
292 config.include[pos] = new_entry;
293 } else {
294 config.include.push(new_entry);
295 }
296 } else {
297 config.include.push(new_entry);
298 }
299 config.save(config_path)?;
300 Ok(serde_json::json!({ "ok": true }))
301 }
302 "include.delete" => {
303 let ref_name = params
304 .get("ref")
305 .and_then(|v| v.as_str())
306 .unwrap_or("")
307 .to_string();
308 let mut config = if config_path.exists() {
309 Config::load(config_path)?
310 } else {
311 Config::default()
312 };
313 config.include.retain(|e| e.ref_name() != ref_name);
314 config.save(config_path)?;
315 Ok(serde_json::json!({ "ok": true }))
316 }
317 "sync" => {
318 let msg = api_sync(config_path)?;
319 Ok(serde_json::json!({ "ok": true, "message": msg }))
320 }
321 "cache.clear" => {
322 let config = if config_path.exists() {
323 Config::load(config_path)?
324 } else {
325 Config::default()
326 };
327 let cache_dir = config.cache.cache_dir();
328 let mut cleared = 0u32;
329 if cache_dir.exists() {
330 for entry in std::fs::read_dir(&cache_dir)?.flatten() {
331 if entry.path().extension().map(|x| x == "ok").unwrap_or(false) {
332 std::fs::remove_file(entry.path())?;
333 cleared += 1;
334 }
335 }
336 }
337 Ok(serde_json::json!({ "ok": true, "cleared": cleared }))
338 }
339 "cache.update" => {
340 let mut config = if config_path.exists() {
341 Config::load(config_path)?
342 } else {
343 Config::default()
344 };
345 if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
346 config.cache.enabled = enabled;
347 }
348 if let Some(dir_val) = params.get("dir") {
349 config.cache.dir = dir_val
350 .as_str()
351 .filter(|s| !s.is_empty() && *s != ".githops/cache")
352 .map(|s| s.to_string());
353 }
354 if !config.cache.enabled && config.cache.dir.is_none() {
356 config.cache = GlobalCache::default();
357 }
358 config.save(config_path)?;
359 Ok(serde_json::json!({ "ok": true }))
360 }
361 other => anyhow::bail!("unknown method: {other}"),
362 }
363}
364
365fn api_state(config_path: &Path) -> Result<String> {
370 let config = if config_path.exists() {
371 Config::load(config_path)?
372 } else {
373 Config::default()
374 };
375 let hooks_dir_path = hooks_dir().unwrap_or_else(|_| PathBuf::from(".git/hooks"));
376
377 let cache_dir = config.cache.cache_dir();
379 let cache_dir_str = config.cache.dir.as_deref().unwrap_or(".githops/cache").to_string();
380 let cache_entries: Vec<serde_json::Value> = if cache_dir.exists() {
381 std::fs::read_dir(&cache_dir)
382 .into_iter()
383 .flatten()
384 .flatten()
385 .filter(|e| e.path().extension().map(|x| x == "ok").unwrap_or(false))
386 .map(|e| {
387 let key = e
388 .path()
389 .file_stem()
390 .unwrap_or_default()
391 .to_string_lossy()
392 .to_string();
393 let age_ms = e
394 .metadata()
395 .ok()
396 .and_then(|m| m.modified().ok())
397 .and_then(|t| SystemTime::now().duration_since(t).ok())
398 .map(|d| d.as_millis() as u64)
399 .unwrap_or(0);
400 serde_json::json!({ "key": key, "ageMs": age_ms })
401 })
402 .collect()
403 } else {
404 vec![]
405 };
406
407 let hook_states: Vec<serde_json::Value> = ALL_HOOKS
408 .iter()
409 .map(|info| {
410 let installed = hooks_dir_path.join(info.name).exists();
411 let cfg = config.hooks.get(info.name);
412 let commands: Vec<serde_json::Value> = cfg
413 .map(|c| {
414 c.commands
415 .iter()
416 .map(|entry| match entry {
417 CommandEntry::Include(inc) => {
418 let resolved_run = resolve_include_entry(inc, &config.include)
419 .map(|cmd| cmd.run)
420 .unwrap_or_default();
421 let display_name = inc.name.as_deref().unwrap_or_else(|| {
422 inc.run.split('.').last().unwrap_or(inc.run.as_str())
423 });
424 serde_json::json!({
425 "isRef": false,
426 "isInclude": true,
427 "includeRef": inc.include_ref,
428 "includePath": inc.run,
429 "args": inc.args.as_deref().unwrap_or(""),
430 "name": display_name,
431 "nameOverride": inc.name.as_deref().unwrap_or(""),
432 "run": resolved_run,
433 "refName": "",
434 "refArgs": "",
435 "depends": [],
436 "env": inc.env,
437 "test": false,
438 })
439 }
440 CommandEntry::Ref(r) => {
441 let (def_name, def_run) = config
442 .definitions
443 .get(&r.r#ref)
444 .and_then(|d| match d {
445 DefinitionEntry::Single(cmd) => {
446 Some((cmd.name.clone(), cmd.run.clone()))
447 }
448 _ => None,
449 })
450 .unwrap_or_else(|| (r.r#ref.clone(), String::new()));
451 serde_json::json!({
452 "isRef": true,
453 "refName": r.r#ref,
454 "name": r.name.as_deref().unwrap_or(&def_name),
455 "nameOverride": r.name.as_deref().unwrap_or(""),
456 "run": def_run,
457 "refArgs": r.args.as_deref().unwrap_or(""),
458 "depends": [],
459 "env": {},
460 "test": false,
461 })
462 }
463 CommandEntry::Inline(cmd) => serde_json::json!({
464 "isRef": false,
465 "refName": "",
466 "name": cmd.name,
467 "run": cmd.run,
468 "depends": cmd.depends,
469 "env": cmd.env,
470 "test": cmd.test,
471 "cache": cmd.cache.as_ref().map(|c| serde_json::json!({
472 "inputs": c.inputs,
473 "key": c.key,
474 })),
475 }),
476 })
477 .collect()
478 })
479 .unwrap_or_default();
480
481 serde_json::json!({
482 "name": info.name,
483 "description": info.description,
484 "category": info.category.label(),
485 "configured": cfg.is_some(),
486 "installed": installed,
487 "enabled": cfg.map(|c| c.enabled).unwrap_or(false),
488 "parallel": cfg.map(|c| c.parallel).unwrap_or(false),
489 "commands": commands,
490 })
491 })
492 .collect();
493
494 let mut seen: HashSet<String> = HashSet::new();
495 let mut unique_commands: Vec<serde_json::Value> = Vec::new();
496 for hook_info in ALL_HOOKS {
497 if let Some(cfg) = config.hooks.get(hook_info.name) {
498 for entry in &cfg.commands {
499 if let CommandEntry::Inline(cmd) = entry {
500 if seen.insert(cmd.name.clone()) {
501 let used_in: Vec<&str> = ALL_HOOKS
502 .iter()
503 .filter(|h| {
504 config
505 .hooks
506 .get(h.name)
507 .map(|c| {
508 c.commands.iter().any(|e| {
509 if let CommandEntry::Inline(ic) = e {
510 ic.name == cmd.name
511 } else {
512 false
513 }
514 })
515 })
516 .unwrap_or(false)
517 })
518 .map(|h| h.name)
519 .collect();
520 unique_commands.push(serde_json::json!({
521 "name": cmd.name,
522 "run": cmd.run,
523 "test": cmd.test,
524 "usedIn": used_in,
525 }));
526 }
527 }
528 }
529 }
530 }
531
532 let definitions: Vec<serde_json::Value> = config
533 .definitions
534 .iter()
535 .map(|(name, def)| {
536 let (def_type, cmds) = match def {
537 DefinitionEntry::Single(cmd) => (
538 "single",
539 vec![serde_json::json!({
540 "name": cmd.name, "run": cmd.run,
541 "depends": cmd.depends, "env": cmd.env, "test": cmd.test,
542 })],
543 ),
544 DefinitionEntry::List(cmds) => (
545 "list",
546 cmds.iter()
547 .map(|cmd| {
548 serde_json::json!({
549 "name": cmd.name, "run": cmd.run,
550 "depends": cmd.depends, "env": cmd.env, "test": cmd.test,
551 })
552 })
553 .collect(),
554 ),
555 };
556 serde_json::json!({ "name": name, "type": def_type, "commands": cmds })
557 })
558 .collect();
559
560 let includes: Vec<serde_json::Value> = config.include.iter().map(|e| {
561 match e {
562 IncludeEntry::Local(l) => serde_json::json!({
563 "source": "local",
564 "ref": l.ref_name,
565 "path": l.path,
566 "type": match l.file_type { IncludeType::Json => "json", IncludeType::Toml => "toml", IncludeType::Yaml => "yaml" },
567 }),
568 IncludeEntry::Remote(r) => serde_json::json!({
569 "source": "remote",
570 "ref": r.ref_name,
571 "url": r.url,
572 "type": match r.file_type { IncludeType::Json => "json", IncludeType::Toml => "toml", IncludeType::Yaml => "yaml" },
573 }),
574 IncludeEntry::Git(g) => serde_json::json!({
575 "source": "git",
576 "ref": g.ref_name,
577 "url": g.url,
578 "rev": g.rev,
579 "file": g.file,
580 "type": match g.file_type { IncludeType::Json => "json", IncludeType::Toml => "toml", IncludeType::Yaml => "yaml" },
581 }),
582 }
583 }).collect();
584
585 Ok(serde_json::to_string(&serde_json::json!({
586 "hooks": hook_states,
587 "commands": unique_commands,
588 "definitions": definitions,
589 "includes": includes,
590 "configExists": config_path.exists(),
591 "cacheStatus": {
592 "enabled": config.cache.enabled,
593 "dir": cache_dir_str,
594 "entries": cache_entries,
595 },
596 }))?)
597}
598
599fn null_as_default<'de, D, T>(d: D) -> Result<T, D::Error>
600where
601 D: serde::Deserializer<'de>,
602 T: Default + serde::Deserialize<'de>,
603{
604 use serde::Deserialize;
605 Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
606}
607
608#[derive(serde::Deserialize)]
609struct UpdateRequest {
610 action: String,
611 #[serde(default, deserialize_with = "null_as_default")]
612 hook: String,
613 #[serde(default)]
614 enabled: bool,
615 #[serde(default)]
616 parallel: bool,
617 #[serde(default)]
618 commands: Vec<CommandDto>,
619 #[serde(default, rename = "oldName", deserialize_with = "null_as_default")]
620 old_name: String,
621 #[serde(default, deserialize_with = "null_as_default")]
622 name: String,
623 #[serde(default, deserialize_with = "null_as_default")]
624 run: String,
625 #[serde(default, rename = "defType", deserialize_with = "null_as_default")]
626 def_type: String,
627}
628
629#[derive(serde::Deserialize, Default)]
630struct CommandCacheDto {
631 #[serde(default)]
632 inputs: Vec<String>,
633 #[serde(default)]
634 key: Vec<String>,
635}
636
637#[derive(serde::Deserialize)]
638struct CommandDto {
639 #[serde(default, deserialize_with = "null_as_default")]
640 name: String,
641 #[serde(default, deserialize_with = "null_as_default")]
642 run: String,
643 #[serde(default)]
644 depends: Vec<String>,
645 #[serde(default)]
646 env: BTreeMap<String, String>,
647 #[serde(default)]
648 test: bool,
649 #[serde(default, rename = "isRef")]
650 is_ref: bool,
651 #[serde(default, rename = "refName", deserialize_with = "null_as_default")]
652 ref_name: String,
653 #[serde(default, rename = "refArgs", deserialize_with = "null_as_default")]
655 ref_args: String,
656 #[serde(default, rename = "nameOverride", deserialize_with = "null_as_default")]
658 name_override: String,
659 #[serde(default)]
660 cache: Option<CommandCacheDto>,
661 #[serde(default, rename = "isInclude")]
662 is_include: bool,
663 #[serde(default, rename = "includeRef", deserialize_with = "null_as_default")]
664 include_ref: String,
665 #[serde(default, rename = "includePath", deserialize_with = "null_as_default")]
667 include_path: String,
668 #[serde(default, rename = "args", deserialize_with = "null_as_default")]
670 include_args: String,
671}
672
673impl CommandDto {
674 fn into_cache(c: CommandCacheDto) -> CommandCache {
675 CommandCache { inputs: c.inputs, key: c.key }
676 }
677
678 fn into_command(self) -> Command {
679 Command {
680 name: self.name,
681 run: self.run,
682 depends: self.depends,
683 env: self.env,
684 test: self.test,
685 cache: self.cache.map(Self::into_cache),
686 }
687 }
688 fn into_entry(self) -> CommandEntry {
689 if self.is_include {
690 CommandEntry::Include(IncludeRef {
691 include_ref: self.include_ref,
692 run: self.include_path,
693 args: if self.include_args.is_empty() { None } else { Some(self.include_args) },
694 env: self.env.clone(),
695 name: if self.name_override.is_empty() { None } else { Some(self.name_override) },
696 })
697 } else if self.is_ref {
698 CommandEntry::Ref(RefEntry {
699 r#ref: self.ref_name,
700 args: if self.ref_args.is_empty() { None } else { Some(self.ref_args) },
701 name: if self.name_override.is_empty() { None } else { Some(self.name_override) },
702 })
703 } else {
704 let cache = self.cache.map(Self::into_cache);
705 CommandEntry::Inline(Command {
706 name: self.name,
707 run: self.run,
708 depends: self.depends,
709 env: self.env,
710 test: self.test,
711 cache,
712 })
713 }
714 }
715}
716
717fn api_update(body: &[u8], config_path: &Path) -> Result<()> {
718 let req: UpdateRequest = serde_json::from_slice(body)?;
719 let mut config = if config_path.exists() {
720 Config::load(config_path)?
721 } else {
722 Config::default()
723 };
724
725 match req.action.as_str() {
726 "update" => {
727 let commands: Vec<CommandEntry> =
728 req.commands.into_iter().map(|c| c.into_entry()).collect();
729 let temp_cfg = HookConfig {
730 enabled: req.enabled,
731 parallel: req.parallel,
732 commands: commands.clone(),
733 };
734 let resolved = temp_cfg.resolved_commands(&config.definitions);
735 githops_core::config::validate_depends_pub(&resolved)?;
736 config.hooks.set(
737 &req.hook,
738 HookConfig { enabled: req.enabled, parallel: req.parallel, commands },
739 );
740 }
741 "remove" => {
742 config.hooks.remove(&req.hook);
743 }
744 "update-command" => {
745 if req.old_name.is_empty() {
746 anyhow::bail!("oldName is required for update-command");
747 }
748 if req.name.is_empty() {
749 anyhow::bail!("name is required for update-command");
750 }
751 update_command_in_all_hooks(&req.old_name, &req.name, &req.run, &mut config);
752 }
753 "update-definition" => {
754 let def_name = req.name.trim().to_string();
755 let old_name = req.old_name.trim().to_string();
756 if def_name.is_empty() {
757 anyhow::bail!("Definition name cannot be empty");
758 }
759 let entry = if req.def_type == "list" {
760 let cmds: Vec<Command> =
761 req.commands.into_iter().map(|c| c.into_command()).collect();
762 DefinitionEntry::List(cmds)
763 } else {
764 let cmd = req
765 .commands
766 .into_iter()
767 .next()
768 .map(|c| c.into_command())
769 .unwrap_or_else(|| Command {
770 name: def_name.clone(),
771 run: req.run,
772 depends: vec![],
773 env: BTreeMap::new(),
774 test: false,
775 cache: None,
776 });
777 DefinitionEntry::Single(cmd)
778 };
779 if !old_name.is_empty() && old_name != def_name {
780 config.definitions.remove(&old_name);
781 update_def_ref_in_all_hooks(&old_name, &def_name, &mut config);
782 }
783 config.definitions.insert(def_name, entry);
784 }
785 "delete-definition" => {
786 let def_name = req.name.trim().to_string();
787 config.definitions.remove(&def_name);
788 remove_def_refs_from_hooks(&def_name, &mut config);
789 }
790 other => anyhow::bail!("Unknown action: {other}"),
791 }
792
793 config.save(config_path)?;
794 Ok(())
795}
796
797fn api_sync(config_path: &Path) -> Result<String> {
798 let config = if config_path.exists() {
799 Config::load(config_path)?
800 } else {
801 anyhow::bail!("No githops.yaml found. Run `githops init` first.");
802 };
803 let dir = hooks_dir()?;
804 let (installed, skipped) = githops_core::sync_hooks::sync_to_hooks(&config, &dir, false)?;
805 Ok(format!(
806 "Synced {} hook(s){}",
807 installed,
808 if skipped > 0 {
809 format!(" ({} skipped)", skipped)
810 } else {
811 String::new()
812 }
813 ))
814}
815
816fn update_command_in_all_hooks(
821 old_name: &str,
822 new_name: &str,
823 new_run: &str,
824 config: &mut Config,
825) {
826 let mut updates: Vec<(&'static str, HookConfig)> = Vec::new();
827 for hook_info in ALL_HOOKS {
828 let hook_cfg = match config.hooks.get(hook_info.name) {
829 Some(cfg) => cfg.clone(),
830 None => continue,
831 };
832 let mut changed = false;
833 let mut new_commands = hook_cfg.commands.clone();
834 for entry in &mut new_commands {
835 if let CommandEntry::Inline(cmd) = entry {
836 if cmd.name == old_name {
837 cmd.name = new_name.to_string();
838 if !new_run.is_empty() {
839 cmd.run = new_run.to_string();
840 }
841 changed = true;
842 }
843 for dep in &mut cmd.depends {
844 if dep == old_name {
845 *dep = new_name.to_string();
846 changed = true;
847 }
848 }
849 }
850 }
851 if changed {
852 updates.push((
853 hook_info.name,
854 HookConfig {
855 enabled: hook_cfg.enabled,
856 parallel: hook_cfg.parallel,
857 commands: new_commands,
858 },
859 ));
860 }
861 }
862 for (name, cfg) in updates {
863 config.hooks.set(name, cfg);
864 }
865}
866
867fn update_def_ref_in_all_hooks(old_name: &str, new_name: &str, config: &mut Config) {
868 let mut updates: Vec<(&'static str, HookConfig)> = Vec::new();
869 for hook_info in ALL_HOOKS {
870 let hook_cfg = match config.hooks.get(hook_info.name) {
871 Some(cfg) => cfg.clone(),
872 None => continue,
873 };
874 let mut changed = false;
875 let mut new_commands = hook_cfg.commands.clone();
876 for entry in &mut new_commands {
877 if let CommandEntry::Ref(r) = entry {
878 if r.r#ref == old_name {
879 r.r#ref = new_name.to_string();
880 changed = true;
881 }
882 }
883 }
884 if changed {
885 updates.push((
886 hook_info.name,
887 HookConfig {
888 enabled: hook_cfg.enabled,
889 parallel: hook_cfg.parallel,
890 commands: new_commands,
891 },
892 ));
893 }
894 }
895 for (name, cfg) in updates {
896 config.hooks.set(name, cfg);
897 }
898}
899
900fn remove_def_refs_from_hooks(def_name: &str, config: &mut Config) {
901 let mut updates: Vec<(&'static str, HookConfig)> = Vec::new();
902 for hook_info in ALL_HOOKS {
903 let hook_cfg = match config.hooks.get(hook_info.name) {
904 Some(cfg) => cfg.clone(),
905 None => continue,
906 };
907 let new_commands: Vec<_> = hook_cfg
908 .commands
909 .iter()
910 .filter(|e| {
911 if let CommandEntry::Ref(r) = e {
912 r.r#ref != def_name
913 } else {
914 true
915 }
916 })
917 .cloned()
918 .collect();
919 if new_commands.len() != hook_cfg.commands.len() {
920 updates.push((
921 hook_info.name,
922 HookConfig {
923 enabled: hook_cfg.enabled,
924 parallel: hook_cfg.parallel,
925 commands: new_commands,
926 },
927 ));
928 }
929 }
930 for (name, cfg) in updates {
931 config.hooks.set(name, cfg);
932 }
933}
934
935fn open_in_browser(url: &str) {
940 #[cfg(target_os = "macos")]
941 let _ = std::process::Command::new("open").arg(url).spawn();
942 #[cfg(target_os = "linux")]
943 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
944 #[cfg(target_os = "windows")]
945 let _ = std::process::Command::new("cmd")
946 .args(["/c", "start", "", url])
947 .spawn();
948}