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