Skip to main content

githops_graphui/
lib.rs

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
26// ---------------------------------------------------------------------------
27// Static UI assets (built by `pnpm run build` in ui/, embedded at compile time)
28// ---------------------------------------------------------------------------
29
30pub 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
34// ---------------------------------------------------------------------------
35// Entry point
36// ---------------------------------------------------------------------------
37
38pub 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
85// ---------------------------------------------------------------------------
86// HTTP handlers
87// ---------------------------------------------------------------------------
88
89async 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
112// ---------------------------------------------------------------------------
113// WebSocket: CDP-style protocol
114// ---------------------------------------------------------------------------
115
116async 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,  // "local" | "remote" | "git"
245                // local fields
246                #[serde(default)]
247                path: String,
248                // remote fields
249                #[serde(default)]
250                url: String,
251                // git fields
252                #[serde(default)]
253                rev: String,
254                #[serde(default)]
255                file: String,
256                // shared
257                #[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 nothing meaningful is set, reset to default (omitted from yaml)
355            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
365// ---------------------------------------------------------------------------
366// API logic
367// ---------------------------------------------------------------------------
368
369fn 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    // ── Cache status ──────────────────────────────────────────────────────────
378    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    /// Extra arguments appended to the definition's run command (refs only).
654    #[serde(default, rename = "refArgs", deserialize_with = "null_as_default")]
655    ref_args: String,
656    /// Explicit name override for this ref use-site (empty = use definition name).
657    #[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    /// Dot-notation path into the included file.
666    #[serde(default, rename = "includePath", deserialize_with = "null_as_default")]
667    include_path: String,
668    /// Extra CLI arguments for an include entry (client sends "args", not "refArgs").
669    #[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
816// ---------------------------------------------------------------------------
817// Config mutation helpers
818// ---------------------------------------------------------------------------
819
820fn 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
935// ---------------------------------------------------------------------------
936// Helpers
937// ---------------------------------------------------------------------------
938
939fn 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}