A monorepo management tool for the agentic ages
at main 652 lines 21 kB view raw
1(** Structured audit logging for unpac operations. *) 2 3let src = Logs.Src.create "unpac.audit" ~doc:"Audit logging" 4module Log = (val Logs.src_log src : Logs.LOG) 5 6(* Git operation types *) 7 8type git_result = { 9 exit_code : int; 10 stdout : string; 11 stderr : string; 12} 13 14type git_operation = { 15 git_id : string; 16 git_timestamp : float; 17 git_cmd : string list; 18 git_cwd : string; 19 git_duration_ms : int; 20 git_result : git_result; 21} 22 23(* Unpac operation types *) 24 25type status = 26 | Success 27 | Failed of string 28 | Conflict of string list 29 30type operation_type = 31 | Init 32 | Project_new 33 | Project_promote 34 | Project_set_remote 35 | Opam_add 36 | Opam_init 37 | Opam_promote 38 | Opam_update 39 | Opam_merge 40 | Opam_edit 41 | Opam_done 42 | Opam_remove 43 | Git_add 44 | Git_update 45 | Git_merge 46 | Git_remove 47 | Push 48 | Unknown of string 49 50type operation = { 51 id : string; 52 timestamp : float; 53 operation_type : operation_type; 54 args : string list; 55 cwd : string; 56 duration_ms : int; 57 status : status; 58 git_operations : git_operation list; 59} 60 61type log = { 62 version : string; 63 entries : operation list; 64} 65 66let current_version = "1.0" 67 68(* UUID generation - simple random hex *) 69let () = Random.self_init () 70 71let generate_id () = 72 let buf = Buffer.create 32 in 73 for _ = 1 to 8 do 74 Buffer.add_string buf (Printf.sprintf "%04x" (Random.int 0x10000)) 75 done; 76 let s = Buffer.contents buf in 77 (* Format as UUID: 8-4-4-4-12 *) 78 Printf.sprintf "%s-%s-%s-%s-%s" 79 (String.sub s 0 8) 80 (String.sub s 8 4) 81 (String.sub s 12 4) 82 (String.sub s 16 4) 83 (String.sub s 20 12) 84 85(* JSON codecs *) 86 87let git_result_jsont = 88 Jsont.Object.map 89 ~kind:"git_result" 90 (fun exit_code stdout stderr -> { exit_code; stdout; stderr }) 91 |> Jsont.Object.mem "exit_code" Jsont.int ~enc:(fun r -> r.exit_code) 92 |> Jsont.Object.mem "stdout" Jsont.string ~enc:(fun r -> r.stdout) 93 |> Jsont.Object.mem "stderr" Jsont.string ~enc:(fun r -> r.stderr) 94 |> Jsont.Object.finish 95 96let git_operation_jsont = 97 Jsont.Object.map 98 ~kind:"git_operation" 99 (fun git_id git_timestamp git_cmd git_cwd git_duration_ms git_result -> 100 { git_id; git_timestamp; git_cmd; git_cwd; git_duration_ms; git_result }) 101 |> Jsont.Object.mem "id" Jsont.string ~enc:(fun g -> g.git_id) 102 |> Jsont.Object.mem "timestamp" Jsont.number ~enc:(fun g -> g.git_timestamp) 103 |> Jsont.Object.mem "cmd" (Jsont.list Jsont.string) ~enc:(fun g -> g.git_cmd) 104 |> Jsont.Object.mem "cwd" Jsont.string ~enc:(fun g -> g.git_cwd) 105 |> Jsont.Object.mem "duration_ms" Jsont.int ~enc:(fun g -> g.git_duration_ms) 106 |> Jsont.Object.mem "result" git_result_jsont ~enc:(fun g -> g.git_result) 107 |> Jsont.Object.finish 108 109let status_jsont = 110 (* Encode status as a simple object with status field and optional data *) 111 Jsont.Object.map ~kind:"status" 112 (fun status data_opt -> 113 match status, data_opt with 114 | "success", _ -> Success 115 | "failed", Some msg -> Failed msg 116 | "conflict", Some files_str -> 117 Conflict (String.split_on_char ',' files_str) 118 | s, _ -> Failed (Printf.sprintf "Unknown status: %s" s)) 119 |> Jsont.Object.mem "status" Jsont.string 120 ~enc:(function 121 | Success -> "success" 122 | Failed _ -> "failed" 123 | Conflict _ -> "conflict") 124 |> Jsont.Object.opt_mem "data" Jsont.string 125 ~enc:(function 126 | Success -> None 127 | Failed msg -> Some msg 128 | Conflict files -> Some (String.concat "," files)) 129 |> Jsont.Object.finish 130 131let operation_type_to_string = function 132 | Init -> "init" 133 | Project_new -> "project.new" 134 | Project_promote -> "project.promote" 135 | Project_set_remote -> "project.set-remote" 136 | Opam_add -> "opam.add" 137 | Opam_init -> "opam.init" 138 | Opam_promote -> "opam.promote" 139 | Opam_update -> "opam.update" 140 | Opam_merge -> "opam.merge" 141 | Opam_edit -> "opam.edit" 142 | Opam_done -> "opam.done" 143 | Opam_remove -> "opam.remove" 144 | Git_add -> "git.add" 145 | Git_update -> "git.update" 146 | Git_merge -> "git.merge" 147 | Git_remove -> "git.remove" 148 | Push -> "push" 149 | Unknown s -> s 150 151let operation_type_of_string = function 152 | "init" -> Init 153 | "project.new" -> Project_new 154 | "project.promote" -> Project_promote 155 | "project.set-remote" -> Project_set_remote 156 | "opam.add" -> Opam_add 157 | "opam.init" -> Opam_init 158 | "opam.promote" -> Opam_promote 159 | "opam.update" -> Opam_update 160 | "opam.merge" -> Opam_merge 161 | "opam.edit" -> Opam_edit 162 | "opam.done" -> Opam_done 163 | "opam.remove" -> Opam_remove 164 | "git.add" -> Git_add 165 | "git.update" -> Git_update 166 | "git.merge" -> Git_merge 167 | "git.remove" -> Git_remove 168 | "push" -> Push 169 | s -> Unknown s 170 171let operation_type_jsont = 172 Jsont.string 173 |> Jsont.map ~dec:operation_type_of_string ~enc:operation_type_to_string 174 175let operation_jsont = 176 Jsont.Object.map 177 ~kind:"operation" 178 (fun id timestamp operation_type args cwd duration_ms status git_operations -> 179 { id; timestamp; operation_type; args; cwd; duration_ms; status; git_operations }) 180 |> Jsont.Object.mem "id" Jsont.string ~enc:(fun o -> o.id) 181 |> Jsont.Object.mem "timestamp" Jsont.number ~enc:(fun o -> o.timestamp) 182 |> Jsont.Object.mem "operation" operation_type_jsont ~enc:(fun o -> o.operation_type) 183 |> Jsont.Object.mem "args" (Jsont.list Jsont.string) ~enc:(fun o -> o.args) 184 |> Jsont.Object.mem "cwd" Jsont.string ~enc:(fun o -> o.cwd) 185 |> Jsont.Object.mem "duration_ms" Jsont.int ~enc:(fun o -> o.duration_ms) 186 |> Jsont.Object.mem "status" status_jsont ~enc:(fun o -> o.status) 187 |> Jsont.Object.mem "git_operations" (Jsont.list git_operation_jsont) 188 ~enc:(fun o -> o.git_operations) 189 |> Jsont.Object.finish 190 191let log_jsont = 192 Jsont.Object.map 193 ~kind:"audit_log" 194 (fun version entries -> { version; entries }) 195 |> Jsont.Object.mem "version" Jsont.string ~enc:(fun l -> l.version) 196 |> Jsont.Object.mem "entries" (Jsont.list operation_jsont) ~enc:(fun l -> l.entries) 197 |> Jsont.Object.finish 198 199(* Context for accumulating git operations *) 200 201type context = { 202 ctx_id : string; 203 ctx_operation_type : operation_type; 204 ctx_args : string list; 205 ctx_cwd : string; 206 ctx_start : float; 207 mutable ctx_git_ops : git_operation list; 208} 209 210let start_operation ~operation_type ~args ~cwd = 211 let ctx = { 212 ctx_id = generate_id (); 213 ctx_operation_type = operation_type; 214 ctx_args = args; 215 ctx_cwd = cwd; 216 ctx_start = Unix.gettimeofday (); 217 ctx_git_ops = []; 218 } in 219 Log.debug (fun m -> m "Starting operation %s: %s %a" 220 ctx.ctx_id (operation_type_to_string operation_type) 221 Fmt.(list ~sep:sp string) args); 222 ctx 223 224let record_git ctx ~cmd ~cwd ~started ~result = 225 let now = Unix.gettimeofday () in 226 let duration_ms = int_of_float ((now -. started) *. 1000.0) in 227 let op = { 228 git_id = generate_id (); 229 git_timestamp = started; 230 git_cmd = cmd; 231 git_cwd = cwd; 232 git_duration_ms = duration_ms; 233 git_result = result; 234 } in 235 ctx.ctx_git_ops <- op :: ctx.ctx_git_ops; 236 Log.debug (fun m -> m "Recorded git: %a (exit %d, %dms)" 237 Fmt.(list ~sep:sp string) cmd result.exit_code duration_ms) 238 239let finalize_operation ctx status = 240 let now = Unix.gettimeofday () in 241 let duration_ms = int_of_float ((now -. ctx.ctx_start) *. 1000.0) in 242 let op = { 243 id = ctx.ctx_id; 244 timestamp = ctx.ctx_start; 245 operation_type = ctx.ctx_operation_type; 246 args = ctx.ctx_args; 247 cwd = ctx.ctx_cwd; 248 duration_ms; 249 status; 250 git_operations = List.rev ctx.ctx_git_ops; 251 } in 252 Log.info (fun m -> m "Completed operation %s in %dms" ctx.ctx_id duration_ms); 253 op 254 255let complete_success ctx = finalize_operation ctx Success 256 257let complete_failed ctx ~error = 258 Log.warn (fun m -> m "Operation %s failed: %s" ctx.ctx_id error); 259 finalize_operation ctx (Failed error) 260 261let complete_conflict ctx ~files = 262 Log.warn (fun m -> m "Operation %s had conflicts in %d files" ctx.ctx_id (List.length files)); 263 finalize_operation ctx (Conflict files) 264 265(* Log file management *) 266 267let default_log_file = ".unpac-audit.json" 268 269let load path = 270 if not (Sys.file_exists path) then 271 Ok { version = current_version; entries = [] } 272 else 273 try 274 let ic = open_in path in 275 let content = really_input_string ic (in_channel_length ic) in 276 close_in ic; 277 match Jsont_bytesrw.decode_string' log_jsont content with 278 | Ok log -> Ok log 279 | Error e -> Error (Printf.sprintf "Parse error: %s" (Jsont.Error.to_string e)) 280 with 281 | Sys_error msg -> Error msg 282 283let save path log = 284 try 285 match Jsont_bytesrw.encode_string ~format:Jsont.Indent log_jsont log with 286 | Ok content -> 287 let oc = open_out path in 288 output_string oc content; 289 close_out oc; 290 Ok () 291 | Error e -> Error (Printf.sprintf "Encode error: %s" e) 292 with 293 | Sys_error msg -> Error msg 294 295let append path op = 296 match load path with 297 | Error e -> Error e 298 | Ok log -> 299 let log' = { log with entries = op :: log.entries } in 300 save path log' 301 302(* Pretty printing *) 303 304let pp_status fmt = function 305 | Success -> Format.fprintf fmt "@{<green>SUCCESS@}" 306 | Failed msg -> Format.fprintf fmt "@{<red>FAILED@}: %s" msg 307 | Conflict files -> 308 Format.fprintf fmt "@{<yellow>CONFLICT@}: %a" 309 Fmt.(list ~sep:comma string) files 310 311let pp_git_operation fmt op = 312 let status_color = if op.git_result.exit_code = 0 then "green" else "red" in 313 Format.fprintf fmt " @{<%s>[%d]@} git %a (%dms)@." 314 status_color op.git_result.exit_code 315 Fmt.(list ~sep:sp string) op.git_cmd 316 op.git_duration_ms 317 318let pp_operation fmt op = 319 let tm = Unix.localtime op.timestamp in 320 Format.fprintf fmt "@[<v>"; 321 Format.fprintf fmt "[%04d-%02d-%02d %02d:%02d:%02d] %s %a@." 322 (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday 323 tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec 324 (operation_type_to_string op.operation_type) 325 Fmt.(list ~sep:sp string) op.args; 326 Format.fprintf fmt " ID: %s | Duration: %dms@." op.id op.duration_ms; 327 Format.fprintf fmt " Status: %a@." pp_status op.status; 328 if op.git_operations <> [] then begin 329 Format.fprintf fmt " Git operations (%d):@." (List.length op.git_operations); 330 List.iter (pp_git_operation fmt) op.git_operations 331 end; 332 Format.fprintf fmt "@]" 333 334let pp_log fmt log = 335 Format.fprintf fmt "@[<v>Unpac Audit Log (version %s)@." log.version; 336 Format.fprintf fmt "Total operations: %d@.@." (List.length log.entries); 337 List.iter (fun op -> 338 pp_operation fmt op; 339 Format.fprintf fmt "@." 340 ) log.entries; 341 Format.fprintf fmt "@]" 342 343(* HTML generation *) 344 345let html_escape s = 346 let buf = Buffer.create (String.length s) in 347 String.iter (function 348 | '<' -> Buffer.add_string buf "&lt;" 349 | '>' -> Buffer.add_string buf "&gt;" 350 | '&' -> Buffer.add_string buf "&amp;" 351 | '"' -> Buffer.add_string buf "&quot;" 352 | c -> Buffer.add_char buf c 353 ) s; 354 Buffer.contents buf 355 356(* Commit audit log to git *) 357 358let commit_log ~proc_mgr ~main_wt ~log_path = 359 (* Stage the audit log *) 360 let rel_path = Filename.basename log_path in 361 let started = Unix.gettimeofday () in 362 let result = 363 try 364 (* Add the file *) 365 Eio.Switch.run @@ fun sw -> 366 let stdout_r, stdout_w = Eio.Process.pipe proc_mgr ~sw in 367 let stderr_r, stderr_w = Eio.Process.pipe proc_mgr ~sw in 368 let child = Eio.Process.spawn proc_mgr ~sw 369 ~cwd:(main_wt :> Eio.Fs.dir_ty Eio.Path.t) 370 ~stdout:stdout_w ~stderr:stderr_w 371 ["git"; "add"; rel_path] 372 in 373 Eio.Flow.close stdout_w; 374 Eio.Flow.close stderr_w; 375 (* Drain outputs *) 376 let stdout_buf = Buffer.create 64 in 377 let stderr_buf = Buffer.create 64 in 378 Eio.Fiber.both 379 (fun () -> 380 try 381 while true do 382 let chunk = Cstruct.create 1024 in 383 let n = Eio.Flow.single_read stdout_r chunk in 384 Buffer.add_string stdout_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)) 385 done 386 with End_of_file -> ()) 387 (fun () -> 388 try 389 while true do 390 let chunk = Cstruct.create 1024 in 391 let n = Eio.Flow.single_read stderr_r chunk in 392 Buffer.add_string stderr_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)) 393 done 394 with End_of_file -> ()); 395 let status = Eio.Process.await child in 396 match status with 397 | `Exited 0 -> Ok () 398 | `Exited code -> Error (Printf.sprintf "git add failed (exit %d): %s" code (Buffer.contents stderr_buf)) 399 | `Signaled sig_ -> Error (Printf.sprintf "git add killed by signal %d" sig_) 400 with exn -> Error (Printf.sprintf "Exception: %s" (Printexc.to_string exn)) 401 in 402 match result with 403 | Error e -> 404 Log.warn (fun m -> m "Failed to stage audit log: %s" e); 405 Error e 406 | Ok () -> 407 (* Commit the file *) 408 let result = 409 try 410 Eio.Switch.run @@ fun sw -> 411 let stdout_r, stdout_w = Eio.Process.pipe proc_mgr ~sw in 412 let stderr_r, stderr_w = Eio.Process.pipe proc_mgr ~sw in 413 let child = Eio.Process.spawn proc_mgr ~sw 414 ~cwd:(main_wt :> Eio.Fs.dir_ty Eio.Path.t) 415 ~stdout:stdout_w ~stderr:stderr_w 416 ["git"; "commit"; "-m"; "Update audit log"; "--no-verify"] 417 in 418 Eio.Flow.close stdout_w; 419 Eio.Flow.close stderr_w; 420 (* Drain outputs *) 421 let stdout_buf = Buffer.create 64 in 422 let stderr_buf = Buffer.create 64 in 423 Eio.Fiber.both 424 (fun () -> 425 try 426 while true do 427 let chunk = Cstruct.create 1024 in 428 let n = Eio.Flow.single_read stdout_r chunk in 429 Buffer.add_string stdout_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)) 430 done 431 with End_of_file -> ()) 432 (fun () -> 433 try 434 while true do 435 let chunk = Cstruct.create 1024 in 436 let n = Eio.Flow.single_read stderr_r chunk in 437 Buffer.add_string stderr_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)) 438 done 439 with End_of_file -> ()); 440 let status = Eio.Process.await child in 441 match status with 442 | `Exited 0 -> Ok () 443 | `Exited 1 when String.length (Buffer.contents stdout_buf) > 0 && 444 (String.exists (fun c -> c = 'n') (Buffer.contents stdout_buf)) -> 445 (* "nothing to commit" - this is fine *) 446 Ok () 447 | `Exited code -> Error (Printf.sprintf "git commit failed (exit %d): %s" code (Buffer.contents stderr_buf)) 448 | `Signaled sig_ -> Error (Printf.sprintf "git commit killed by signal %d" sig_) 449 with exn -> Error (Printf.sprintf "Exception: %s" (Printexc.to_string exn)) 450 in 451 let duration = int_of_float ((Unix.gettimeofday () -. started) *. 1000.0) in 452 (match result with 453 | Ok () -> Log.debug (fun m -> m "Committed audit log (%dms)" duration) 454 | Error e -> Log.warn (fun m -> m "Failed to commit audit log: %s" e)); 455 result 456 457(** Full audit manager that wraps operations *) 458type manager = { 459 proc_mgr : [ `Generic | `Unix ] Eio.Process.mgr_ty Eio.Resource.t; 460 main_wt : Eio.Fs.dir_ty Eio.Path.t; 461 log_path : string; 462 mutable current_ctx : context option; 463} 464 465let create_manager ~proc_mgr ~main_wt = 466 let log_path = Eio.Path.(main_wt / default_log_file) |> snd in 467 { proc_mgr; main_wt; log_path; current_ctx = None } 468 469let begin_operation mgr ~operation_type ~args = 470 let cwd = snd mgr.main_wt in 471 let ctx = start_operation ~operation_type ~args ~cwd in 472 mgr.current_ctx <- Some ctx; 473 ctx 474 475let end_operation mgr status = 476 match mgr.current_ctx with 477 | None -> 478 Log.warn (fun m -> m "end_operation called without active context"); 479 Error "No active operation" 480 | Some ctx -> 481 mgr.current_ctx <- None; 482 let op = finalize_operation ctx status in 483 (* Append to log file *) 484 (match append mgr.log_path op with 485 | Error e -> 486 Log.err (fun m -> m "Failed to append to audit log: %s" e); 487 Error e 488 | Ok () -> 489 (* Commit the log *) 490 match commit_log ~proc_mgr:mgr.proc_mgr ~main_wt:mgr.main_wt ~log_path:mgr.log_path with 491 | Error e -> 492 Log.warn (fun m -> m "Failed to commit audit log (will retry next operation): %s" e); 493 Ok op (* Still return success - the log is saved, just not committed *) 494 | Ok () -> 495 Ok op) 496 497let end_success mgr = end_operation mgr Success 498let end_failed mgr ~error = end_operation mgr (Failed error) 499let end_conflict mgr ~files = end_operation mgr (Conflict files) 500 501let get_context mgr = mgr.current_ctx 502 503let to_html log = 504 let buf = Buffer.create 4096 in 505 let add = Buffer.add_string buf in 506 add {|<!DOCTYPE html> 507<html lang="en"> 508<head> 509 <meta charset="UTF-8"> 510 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 511 <title>Unpac Audit Log</title> 512 <style> 513 :root { 514 --bg: #1a1a2e; 515 --card: #16213e; 516 --text: #e4e4e4; 517 --accent: #0f3460; 518 --success: #4ecca3; 519 --error: #e94560; 520 --warning: #f39c12; 521 } 522 body { 523 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 524 background: var(--bg); 525 color: var(--text); 526 margin: 0; 527 padding: 20px; 528 line-height: 1.6; 529 } 530 h1 { color: var(--success); margin-bottom: 10px; } 531 .meta { color: #888; margin-bottom: 30px; } 532 .operation { 533 background: var(--card); 534 border-radius: 8px; 535 padding: 20px; 536 margin-bottom: 20px; 537 border-left: 4px solid var(--accent); 538 } 539 .operation.success { border-left-color: var(--success); } 540 .operation.failed { border-left-color: var(--error); } 541 .operation.conflict { border-left-color: var(--warning); } 542 .op-header { 543 display: flex; 544 justify-content: space-between; 545 align-items: center; 546 margin-bottom: 10px; 547 } 548 .op-type { 549 font-weight: bold; 550 font-size: 1.1em; 551 color: var(--success); 552 } 553 .op-time { color: #888; font-size: 0.9em; } 554 .op-args { font-family: monospace; color: #888; margin: 5px 0; } 555 .status { 556 display: inline-block; 557 padding: 2px 8px; 558 border-radius: 4px; 559 font-size: 0.85em; 560 font-weight: bold; 561 } 562 .status.success { background: var(--success); color: #000; } 563 .status.failed { background: var(--error); color: #fff; } 564 .status.conflict { background: var(--warning); color: #000; } 565 .git-ops { 566 margin-top: 15px; 567 padding-top: 15px; 568 border-top: 1px solid var(--accent); 569 } 570 .git-ops summary { 571 cursor: pointer; 572 color: #888; 573 } 574 .git-op { 575 font-family: monospace; 576 font-size: 0.9em; 577 padding: 5px 10px; 578 margin: 5px 0; 579 background: var(--accent); 580 border-radius: 4px; 581 } 582 .git-op.error { border-left: 3px solid var(--error); } 583 .git-cmd { color: var(--success); } 584 .git-exit { color: #888; } 585 .git-duration { color: #888; float: right; } 586 </style> 587</head> 588<body> 589 <h1>Unpac Audit Log</h1> 590 <div class="meta">Version |}; 591 add (html_escape log.version); 592 add {| | |}; 593 add (string_of_int (List.length log.entries)); 594 add {| operations</div> 595|}; 596 List.iter (fun op -> 597 let status_class = match op.status with 598 | Success -> "success" 599 | Failed _ -> "failed" 600 | Conflict _ -> "conflict" 601 in 602 let tm = Unix.localtime op.timestamp in 603 add (Printf.sprintf {| <div class="operation %s"> 604 <div class="op-header"> 605 <span class="op-type">%s</span> 606 <span class="op-time">%04d-%02d-%02d %02d:%02d:%02d (%dms)</span> 607 </div> 608 <div class="op-args">%s</div> 609 <span class="status %s">%s</span> 610|} 611 status_class 612 (html_escape (operation_type_to_string op.operation_type)) 613 (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday 614 tm.Unix.tm_hour tm.Unix.tm_min tm.Unix.tm_sec 615 op.duration_ms 616 (html_escape (String.concat " " op.args)) 617 status_class 618 (match op.status with 619 | Success -> "SUCCESS" 620 | Failed msg -> "FAILED: " ^ html_escape msg 621 | Conflict files -> "CONFLICT: " ^ html_escape (String.concat ", " files))); 622 if op.git_operations <> [] then begin 623 add {| <div class="git-ops"> 624 <details> 625 <summary>|}; 626 add (string_of_int (List.length op.git_operations)); 627 add {| git operations</summary> 628|}; 629 List.iter (fun git_op -> 630 let error_class = if git_op.git_result.exit_code <> 0 then " error" else "" in 631 add (Printf.sprintf {| <div class="git-op%s"> 632 <span class="git-cmd">git %s</span> 633 <span class="git-duration">%dms</span> 634 <span class="git-exit">[exit %d]</span> 635 </div> 636|} 637 error_class 638 (html_escape (String.concat " " git_op.git_cmd)) 639 git_op.git_duration_ms 640 git_op.git_result.exit_code) 641 ) op.git_operations; 642 add {| </details> 643 </div> 644|} 645 end; 646 add {| </div> 647|} 648 ) log.entries; 649 add {|</body> 650</html> 651|}; 652 Buffer.contents buf