A monorepo management tool for the agentic ages
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 "<"
349 | '>' -> Buffer.add_string buf ">"
350 | '&' -> Buffer.add_string buf "&"
351 | '"' -> Buffer.add_string buf """
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