My aggregated monorepo of OCaml code, automaintained
at main 313 lines 12 kB view raw
1type checkout_status = 2 | Missing 3 | Not_a_repo 4 | Dirty 5 | Clean of Git.ahead_behind 6 7type subtree_status = Not_added | Present 8 9(** Sync state between monorepo subtree and local checkout *) 10type subtree_sync = 11 | In_sync (** Subtree matches checkout HEAD *) 12 | Subtree_behind of int 13 (** Subtree needs pull from checkout (checkout has new commits) *) 14 | Subtree_ahead of int 15 (** Subtree has commits not in checkout (need push to checkout) *) 16 | Trees_differ (** Trees differ but can't determine direction/count *) 17 | Unknown (** Can't determine (subtree not added or checkout missing) *) 18 19type t = { 20 package : Package.t; 21 checkout : checkout_status; 22 subtree : subtree_status; 23 subtree_sync : subtree_sync; (** Sync state between monorepo and checkout *) 24} 25 26let fs_typed (fs : _ Eio.Path.t) : Eio.Fs.dir_ty Eio.Path.t = 27 let dir, _ = fs in 28 (dir, "") 29 30let compute ~proc ~fs ~config pkg = 31 let checkouts_root = Config.Paths.checkouts config in 32 let checkout_dir = Package.checkout_dir ~checkouts_root pkg in 33 let monorepo = Config.Paths.monorepo config in 34 let prefix = Package.subtree_prefix pkg in 35 let fs_t = fs_typed fs in 36 let fs_dir = 37 let dir, _ = fs in 38 (dir, Fpath.to_string checkout_dir) 39 in 40 let checkout = 41 match Eio.Path.kind ~follow:true fs_dir with 42 | exception Eio.Io _ -> Missing 43 | `Directory -> ( 44 if not (Git.is_repo ~proc ~fs:fs_t checkout_dir) then Not_a_repo 45 else if Git.is_dirty ~proc ~fs:fs_t checkout_dir then Dirty 46 else 47 match Git.ahead_behind ~proc ~fs:fs_t checkout_dir with 48 | Ok ab -> Clean ab 49 | Error _ -> Clean { ahead = 0; behind = 0 }) 50 | _ -> Missing 51 in 52 let subtree = 53 if Git.Subtree.exists ~fs:fs_t ~repo:monorepo ~prefix then Present 54 else Not_added 55 in 56 (* Compute subtree sync state: compare tree content between monorepo subtree and checkout. 57 This is more accurate than commit ancestry because it handles both push and pull directions. 58 If the trees match, the content is in sync regardless of how it got there. *) 59 let subtree_sync = 60 match (checkout, subtree) with 61 | (Missing | Not_a_repo | Dirty), _ -> Unknown 62 | _, Not_added -> Unknown 63 | Clean _, Present -> ( 64 (* Get tree hash of subtree directory in monorepo *) 65 let subtree_tree = 66 Git.rev_parse ~proc ~fs:fs_t ~rev:("HEAD:" ^ prefix) monorepo 67 in 68 (* Get tree hash of checkout root *) 69 let checkout_tree = 70 Git.rev_parse ~proc ~fs:fs_t ~rev:"HEAD^{tree}" checkout_dir 71 in 72 match (subtree_tree, checkout_tree) with 73 | Ok st, Ok ct when st = ct -> In_sync 74 | Ok _, Ok _ -> ( 75 (* Trees differ - check commit ancestry to determine direction *) 76 let subtree_commit = 77 Git.subtree_last_upstream_commit ~proc ~fs:fs_t ~repo:monorepo 78 ~prefix () 79 in 80 let checkout_head = Git.head_commit ~proc ~fs:fs_t checkout_dir in 81 match (subtree_commit, checkout_head) with 82 | Some subtree_sha, Ok checkout_sha -> 83 if 84 Git.is_ancestor ~proc ~fs:fs_t ~repo:checkout_dir 85 ~commit1:subtree_sha ~commit2:checkout_sha () 86 then 87 (* Checkout has commits not in subtree - need subtree pull *) 88 let count = 89 Git.count_commits_between ~proc ~fs:fs_t ~repo:checkout_dir 90 ~base:subtree_sha ~head:checkout_sha () 91 in 92 if count > 0 then Subtree_behind count else Trees_differ 93 (* Same commit but trees differ - monorepo has changes *) 94 else if 95 Git.is_ancestor ~proc ~fs:fs_t ~repo:checkout_dir 96 ~commit1:checkout_sha ~commit2:subtree_sha () 97 then 98 (* Subtree has content not in checkout - need push *) 99 let count = 100 Git.count_commits_between ~proc ~fs:fs_t ~repo:checkout_dir 101 ~base:checkout_sha ~head:subtree_sha () 102 in 103 if count > 0 then Subtree_ahead count else Trees_differ 104 else Trees_differ (* Diverged *) 105 | _ -> Trees_differ 106 (* Trees differ but can't determine ancestry *)) 107 | _ -> Unknown) 108 in 109 { package = pkg; checkout; subtree; subtree_sync } 110 111let compute_all ~proc ~fs ~config packages = 112 List.map (compute ~proc ~fs ~config) packages 113 114let is_checkout_clean t = match t.checkout with Clean _ -> true | _ -> false 115let has_local_changes t = match t.checkout with Dirty -> true | _ -> false 116 117let needs_pull t = 118 match t.checkout with Clean ab -> ab.behind > 0 | _ -> false 119 120let needs_push t = match t.checkout with Clean ab -> ab.ahead > 0 | _ -> false 121 122(** Needs local sync: monorepo subtree out of sync with checkout *) 123let needs_local_sync t = 124 match t.subtree_sync with 125 | Subtree_behind _ | Subtree_ahead _ | Trees_differ -> true 126 | In_sync | Unknown -> false 127 128(** Needs remote action: checkout ahead/behind of upstream *) 129let needs_remote_action t = 130 match t.checkout with Clean ab -> ab.ahead > 0 || ab.behind > 0 | _ -> false 131 132let is_fully_synced t = 133 match (t.checkout, t.subtree, t.subtree_sync) with 134 | Clean ab, Present, In_sync -> ab.ahead = 0 && ab.behind = 0 135 | _ -> false 136 137let filter_actionable statuses = 138 List.filter 139 (fun t -> 140 match t.checkout with 141 | Missing | Not_a_repo | Dirty -> true 142 | Clean ab -> 143 ab.ahead > 0 || ab.behind > 0 || t.subtree = Not_added 144 || needs_local_sync t) 145 statuses 146 147let pp_checkout_status ppf = function 148 | Missing -> Fmt.string ppf "missing" 149 | Not_a_repo -> Fmt.string ppf "not a repo" 150 | Dirty -> Fmt.string ppf "dirty" 151 | Clean ab -> 152 if ab.ahead = 0 && ab.behind = 0 then Fmt.string ppf "clean" 153 else Fmt.pf ppf "ahead %d, behind %d" ab.ahead ab.behind 154 155let pp_subtree_status ppf = function 156 | Not_added -> Fmt.string ppf "not added" 157 | Present -> Fmt.string ppf "present" 158 159let pp ppf t = 160 Fmt.pf ppf "@[<h>%-20s checkout: %a subtree: %a@]" (Package.name t.package) 161 pp_checkout_status t.checkout pp_subtree_status t.subtree 162 163(** Extract handle from a tangled.org URL like "git+https://tangled.org/handle/repo" *) 164let extract_handle_from_url url = 165 let url = if String.starts_with ~prefix:"git+" url then 166 String.sub url 4 (String.length url - 4) 167 else url in 168 let uri = Uri.of_string url in 169 match Uri.host uri with 170 | Some "tangled.org" -> 171 let path = Uri.path uri in 172 (* Path is like "/handle/repo" - extract first component *) 173 let path = if String.length path > 0 && path.[0] = '/' then 174 String.sub path 1 (String.length path - 1) 175 else path in 176 (match String.index_opt path '/' with 177 | Some i -> Some (String.sub path 0 i) 178 | None -> Some path) 179 | _ -> None 180 181(** Format origin indicator from sources registry entry *) 182let pp_origin_indicator ppf entry = 183 match entry with 184 | None -> () 185 | Some Sources_registry.{ origin = Some Sources_registry.Fork; _ } -> 186 Fmt.pf ppf " %a" Fmt.(styled `Magenta string) "^" 187 | Some Sources_registry.{ origin = Some Sources_registry.Join; upstream = Some url; _ } -> 188 (match extract_handle_from_url url with 189 | Some handle -> 190 (* Abbreviate handle - take first part before dot, max 8 chars *) 191 let abbrev = match String.index_opt handle '.' with 192 | Some i -> String.sub handle 0 i 193 | None -> handle 194 in 195 let abbrev = if String.length abbrev > 8 then String.sub abbrev 0 8 else abbrev in 196 Fmt.pf ppf " %a" Fmt.(styled `Cyan (fun ppf s -> pf ppf "v:%s" s)) abbrev 197 | None -> Fmt.pf ppf " %a" Fmt.(styled `Cyan string) "v:") 198 | Some Sources_registry.{ origin = Some Sources_registry.Join; _ } -> 199 Fmt.pf ppf " %a" Fmt.(styled `Cyan string) "v:" 200 | Some _ -> () 201 202(** Compact status for actionable items with colors *) 203let pp_compact ?sources ppf t = 204 let name = Package.name t.package in 205 let subtree = Package.subtree_prefix t.package in 206 let entry = Option.bind sources (fun s -> Sources_registry.find s ~subtree) in 207 (* Helper to print remote sync info *) 208 let pp_remote ab = 209 if ab.Git.ahead > 0 && ab.behind > 0 then 210 Fmt.pf ppf " %a" 211 Fmt.(styled `Yellow (fun ppf (a, b) -> pf ppf "remote:+%d/-%d" a b)) 212 (ab.ahead, ab.behind) 213 else if ab.ahead > 0 then 214 Fmt.pf ppf " %a" 215 Fmt.(styled `Cyan (fun ppf n -> pf ppf "remote:+%d" n)) 216 ab.ahead 217 else if ab.behind > 0 then 218 Fmt.pf ppf " %a" 219 Fmt.(styled `Red (fun ppf n -> pf ppf "remote:-%d" n)) 220 ab.behind 221 in 222 match (t.checkout, t.subtree, t.subtree_sync) with 223 (* Local sync issues with count *) 224 | Clean ab, Present, Subtree_behind n -> 225 Fmt.pf ppf "%-22s %a" name 226 Fmt.(styled `Blue (fun ppf n -> pf ppf "local:-%d" n)) 227 n; 228 pp_remote ab; 229 pp_origin_indicator ppf entry 230 | Clean ab, Present, Subtree_ahead n -> 231 Fmt.pf ppf "%-22s %a" name 232 Fmt.(styled `Blue (fun ppf n -> pf ppf "local:+%d" n)) 233 n; 234 pp_remote ab; 235 pp_origin_indicator ppf entry 236 (* Trees differ but can't determine count *) 237 | Clean ab, Present, Trees_differ -> 238 Fmt.pf ppf "%-22s %a" name Fmt.(styled `Blue string) "local:sync"; 239 pp_remote ab; 240 pp_origin_indicator ppf entry 241 (* Remote sync issues only *) 242 | Clean ab, Present, (In_sync | Unknown) when ab.ahead > 0 && ab.behind > 0 -> 243 Fmt.pf ppf "%-22s %a" name 244 Fmt.(styled `Yellow (fun ppf (a, b) -> pf ppf "remote:+%d/-%d" a b)) 245 (ab.ahead, ab.behind); 246 pp_origin_indicator ppf entry 247 | Clean ab, Present, (In_sync | Unknown) when ab.ahead > 0 -> 248 Fmt.pf ppf "%-22s %a" name 249 Fmt.(styled `Cyan (fun ppf n -> pf ppf "remote:+%d" n)) 250 ab.ahead; 251 pp_origin_indicator ppf entry 252 | Clean ab, Present, (In_sync | Unknown) when ab.behind > 0 -> 253 Fmt.pf ppf "%-22s %a" name 254 Fmt.(styled `Red (fun ppf n -> pf ppf "remote:-%d" n)) 255 ab.behind; 256 pp_origin_indicator ppf entry 257 (* Other issues *) 258 | Clean _, Not_added, _ -> 259 Fmt.pf ppf "%-22s %a" name Fmt.(styled `Magenta string) "(no subtree)"; 260 pp_origin_indicator ppf entry 261 | Missing, _, _ -> 262 Fmt.pf ppf "%-22s %a" name Fmt.(styled `Red string) "(no checkout)"; 263 pp_origin_indicator ppf entry 264 | Not_a_repo, _, _ -> 265 Fmt.pf ppf "%-22s %a" name Fmt.(styled `Red string) "(not a repo)"; 266 pp_origin_indicator ppf entry 267 | Dirty, _, _ -> 268 Fmt.pf ppf "%-22s %a" name Fmt.(styled `Yellow string) "(dirty)"; 269 pp_origin_indicator ppf entry 270 | Clean _, Present, (In_sync | Unknown) -> 271 Fmt.pf ppf "%-22s %a" name Fmt.(styled `Green string) "ok"; 272 pp_origin_indicator ppf entry 273 274let pp_summary ?sources ppf statuses = 275 let total = List.length statuses in 276 let actionable = filter_actionable statuses in 277 let synced = List.filter is_fully_synced statuses |> List.length in 278 let dirty = List.filter has_local_changes statuses |> List.length in 279 let local_sync_needed = 280 List.filter needs_local_sync statuses |> List.length 281 in 282 let remote_needed = List.filter needs_remote_action statuses |> List.length in 283 let action_count = List.length actionable in 284 (* Header line with colors *) 285 if dirty > 0 then 286 Fmt.pf ppf "%a %d total, %a synced, %a dirty\n" 287 Fmt.(styled `Bold string) 288 "Packages:" total 289 Fmt.(styled `Green int) 290 synced 291 Fmt.(styled `Yellow int) 292 dirty 293 else if action_count > 0 then begin 294 Fmt.pf ppf "%a %d total, %a synced" 295 Fmt.(styled `Bold string) 296 "Packages:" total 297 Fmt.(styled `Green int) 298 synced; 299 if local_sync_needed > 0 then 300 Fmt.pf ppf ", %a local sync" Fmt.(styled `Blue int) local_sync_needed; 301 if remote_needed > 0 then 302 Fmt.pf ppf ", %a remote" Fmt.(styled `Cyan int) remote_needed; 303 Fmt.pf ppf "\n" 304 end 305 else 306 Fmt.pf ppf "%a %d total, %a\n" 307 Fmt.(styled `Bold string) 308 "Packages:" total 309 Fmt.(styled `Green string) 310 "all synced"; 311 (* Only show actionable items *) 312 if actionable <> [] then 313 List.iter (fun t -> Fmt.pf ppf " %a\n" (pp_compact ?sources) t) actionable