From c31e1e43c426d6249de6153fbaa5af177d6c6930 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 10:53:24 +0000 Subject: [PATCH] refactor(log): split graph renderer and date formatting out of log.mbt log.mbt had grown to ~7.6k lines. Extract two cohesive, self-contained clusters into sibling files in the same package (no visibility changes): - log_graph.mbt: the `--graph` renderer (GitGraph state machine, a faithful port of git's graph.c). Driven by log_emit_commit_graph, which stays in log.mbt. - log_datefmt.mbt: pure timestamp/calendar helpers (epoch<->calendar, timezone offset parsing, git date output modes). Pure code motion; behavior is unchanged. `moon check --target native` passes with 0 errors and moonc codegen succeeds. log.mbt drops from 7586 to 6732 lines. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WHFC6qxKjqjrixgjFtGihM --- modules/bit/src/cmd/bit/log.mbt | 855 ------------------------ modules/bit/src/cmd/bit/log_datefmt.mbt | 150 +++++ modules/bit/src/cmd/bit/log_graph.mbt | 714 ++++++++++++++++++++ modules/bit/src/cmd/bit/moon.pkg | 2 + 4 files changed, 866 insertions(+), 855 deletions(-) create mode 100644 modules/bit/src/cmd/bit/log_datefmt.mbt create mode 100644 modules/bit/src/cmd/bit/log_graph.mbt diff --git a/modules/bit/src/cmd/bit/log.mbt b/modules/bit/src/cmd/bit/log.mbt index 7119c89a..cad65864 100644 --- a/modules/bit/src/cmd/bit/log.mbt +++ b/modules/bit/src/cmd/bit/log.mbt @@ -3728,152 +3728,6 @@ fn log_format_reflog_selector( display_ref + "@{" + selector_text + "}" } -///| -fn format_timestamp_default(ts : Int64, tz : String) -> String { - let (year, month, day, hour, minute, second) = epoch_to_calendar(ts, tz) - let dow = day_of_week(year, month, day) - let dow_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] - let month_names = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", - "Dec", - ] - let dn = dow_names[dow] - let mn = month_names[month - 1] - "\{dn} \{mn} \{day} \{pad2(hour)}:\{pad2(minute)}:\{pad2(second)} \{year} \{tz}" -} - -///| -fn format_timestamp_default_notz(ts : Int64, tz : String) -> String { - let (year, month, day, hour, minute, second) = epoch_to_calendar(ts, tz) - let dow = day_of_week(year, month, day) - let dow_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] - let month_names = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", - "Dec", - ] - let dn = dow_names[dow] - let mn = month_names[month - 1] - "\{dn} \{mn} \{day} \{pad2(hour)}:\{pad2(minute)}:\{pad2(second)} \{year}" -} - -///| -fn format_timestamp_short(ts : Int64, tz : String) -> String { - let (year, month, day, _, _, _) = epoch_to_calendar(ts, tz) - "\{year}-\{pad2(month)}-\{pad2(day)}" -} - -///| -fn format_timestamp_iso_strict(ts : Int64, tz : String) -> String { - let (year, month, day, hour, minute, second) = epoch_to_calendar(ts, tz) - let tz_formatted = if tz.length() >= 5 { - String::unsafe_substring(tz, start=0, end=3) + - ":" + - String::unsafe_substring(tz, start=3, end=5) - } else { - tz - } - "\{year}-\{pad2(month)}-\{pad2(day)}T\{pad2(hour)}:\{pad2(minute)}:\{pad2(second)}\{tz_formatted}" -} - -///| -fn format_timestamp_iso(ts : Int64, tz : String) -> String { - let (year, month, day, hour, minute, second) = epoch_to_calendar(ts, tz) - let y = year.to_string() - let mo = pad2(month) - let d = pad2(day) - let h = pad2(hour) - let mi = pad2(minute) - let s = pad2(second) - "\{y}-\{mo}-\{d} \{h}:\{mi}:\{s} \{tz}" -} - -///| -fn format_timestamp_rfc2822(ts : Int64, tz : String) -> String { - let (year, month, day, hour, minute, second) = epoch_to_calendar(ts, tz) - let dow = day_of_week(year, month, day) - let dow_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] - let month_names = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", - "Dec", - ] - let dn = dow_names[dow] - let mn = month_names[month - 1] - let d = day.to_string() - let y = year.to_string() - let h = pad2(hour) - let mi = pad2(minute) - let s = pad2(second) - "\{dn}, \{d} \{mn} \{y} \{h}:\{mi}:\{s} \{tz}" -} - -///| -fn pad2(n : Int) -> String { - if n < 10 { - "0" + n.to_string() - } else { - n.to_string() - } -} - -///| -fn epoch_to_calendar(ts : Int64, tz : String) -> (Int, Int, Int, Int, Int, Int) { - // Parse timezone offset - let tz_offset_seconds = parse_tz_offset(tz) - let adjusted = ts + tz_offset_seconds.to_int64() - // Convert epoch seconds to y/m/d h:m:s - let mut days = (adjusted / 86400L).to_int() - let mut remaining = (adjusted % 86400L).to_int() - if remaining < 0 { - days -= 1 - remaining += 86400 - } - let hour = remaining / 3600 - remaining = remaining % 3600 - let minute = remaining / 60 - let second = remaining % 60 - // Days since 1970-01-01 - let (year, month, day) = log_days_to_date(days) - (year, month, day, hour, minute, second) -} - -///| -fn parse_tz_offset(tz : String) -> Int { - if tz.length() < 5 { - return 0 - } - let chars = tz.to_array() - let sign = if chars[0] == '-' { -1 } else { 1 } - let h = (chars[1].to_int() - '0'.to_int()) * 10 + - (chars[2].to_int() - '0'.to_int()) - let m = (chars[3].to_int() - '0'.to_int()) * 10 + - (chars[4].to_int() - '0'.to_int()) - sign * (h * 3600 + m * 60) -} - -///| -fn log_days_to_date(days_since_epoch : Int) -> (Int, Int, Int) { - // Algorithm from http://howardhinnant.github.io/date_algorithms.html - let z = days_since_epoch + 719468 - let era = (if z >= 0 { z } else { z - 146096 }) / 146097 - let doe = z - era * 146097 - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365 - let y = yoe + era * 400 - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100) - let mp = (5 * doy + 2) / 153 - let d = doy - (153 * mp + 2) / 5 + 1 - let m = mp + (if mp < 10 { 3 } else { -9 }) - let year = y + (if m <= 2 { 1 } else { 0 }) - (year, m, d) -} - -///| -fn day_of_week(year : Int, month : Int, day : Int) -> Int { - // Tomohiko Sakamoto's algorithm (0=Sunday) - let t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4] - let y = if month < 3 { year - 1 } else { year } - (y + y / 4 - y / 100 + y / 400 + t[month - 1] + day) % 7 -} - ///| fn resolve_log_reflog_refname( rfs : &@bitcore.RepoFileSystem, @@ -6754,715 +6608,6 @@ fn log_graph_order_sort(entries : Array[LogWalkEntry]) -> Array[LogWalkEntry] { out } -// Graph renderer state constants — a faithful port of git's `enum graph_state` -// (graph.c). The algorithm below mirrors graph.c so that `bit log --graph` -// output is byte-for-byte identical to git's for arbitrary topologies. -let graph_state_padding = 0 - -///| -let graph_state_skip = 1 - -///| -let graph_state_pre_commit = 2 - -///| -let graph_state_commit = 3 - -///| -let graph_state_post_merge = 4 - -///| -let graph_state_collapsing = 5 - -///| merge_chars[] from graph.c: index 0 = '/', 1 = '|', 2 = '\\'. -let graph_merge_chars : Array[Char] = ['/', '|', '\\'] - -///| Port of git's `struct git_graph` (graph.c). Columns hold commit hexes ("" -/// means an unused slot); colors are omitted since `bit log` never colorizes -/// the graph. See third_party/git/graph.c for the field documentation. -struct GitGraph { - mut commit : String - mut commit_is_boundary : Bool - mut num_parents : Int - mut width : Int - mut expansion_row : Int - mut state : Int - mut prev_state : Int - mut commit_index : Int - mut prev_commit_index : Int - mut merge_layout : Int - mut edges_added : Int - mut prev_edges_added : Int - mut num_columns : Int - mut num_new_columns : Int - mut mapping_size : Int - mut columns : Array[String] - mut new_columns : Array[String] - mut mapping : Array[Int] - mut old_mapping : Array[Int] - mut cur_parents : Array[String] - shown : Map[String, Bool] - first_parent_only : Bool - // --graph-lane-limit=: cap the number of visible lanes; lanes at or - // beyond this index are replaced with a "~" truncation mark. 0 (or any - // value <= 0) means no limit. Mirrors git's revs->graph_max_lanes. - graph_max_lanes : Int -} - -///| Port of git's graph_needs_truncation(): true when `lane` is at or beyond -/// the configured lane limit. Values <= 0 mean no limit. -fn graph_needs_truncation(g : GitGraph, lane : Int) -> Bool { - g.graph_max_lanes > 0 && lane >= g.graph_max_lanes -} - -///| -fn GitGraph::new( - shown : Map[String, Bool], - first_parent_only : Bool, - graph_max_lanes : Int, -) -> GitGraph { - { - commit: "", - commit_is_boundary: false, - num_parents: 0, - width: 0, - expansion_row: 0, - state: graph_state_padding, - prev_state: graph_state_padding, - commit_index: 0, - prev_commit_index: 0, - merge_layout: 0, - edges_added: 0, - prev_edges_added: 0, - num_columns: 0, - num_new_columns: 0, - mapping_size: 0, - columns: [], - new_columns: [], - mapping: [], - old_mapping: [], - cur_parents: [], - shown, - first_parent_only, - graph_max_lanes, - } -} - -///| A parent is "interesting" (gets a graph lane drawn to it) if it is part of -/// the walk — i.e. present in the full reachable `shown` set. This mirrors git's -/// graph_is_interesting(), which keys off the revision walk rather than the `-n` -/// output limit: the walk gathers the whole history, so a merge parent just past -/// an `-n` cutoff is still interesting (its lane is kept), while a parent that -/// was never walked (missing/grafted/excluded) is not, so no phantom lane is -/// drawn. With `--first-parent`, only the first parent is ever interesting. -fn graph_interesting_parents( - g : GitGraph, - parents : Array[String], -) -> Array[String] { - if parents.length() == 0 { - return [] - } - if g.first_parent_only { - if g.shown.get(parents[0]).unwrap_or(false) { - return [parents[0]] - } - return [] - } - let res : Array[String] = [] - for p in parents { - if g.shown.get(p).unwrap_or(false) { - res.push(p) - } - } - res -} - -///| -fn graph_update_state(g : GitGraph, s : Int) -> Unit { - g.prev_state = g.state - g.state = s -} - -///| -fn graph_ensure_capacity(g : GitGraph, n : Int) -> Unit { - while g.columns.length() < n { - g.columns.push("") - } - while g.new_columns.length() < n { - g.new_columns.push("") - } - let m = 2 * n + 2 - while g.mapping.length() < m { - g.mapping.push(-1) - } - while g.old_mapping.length() < m { - g.old_mapping.push(-1) - } -} - -///| -fn graph_find_new_column_by_commit(g : GitGraph, commit : String) -> Int { - for i in 0.. Int { - g.num_parents + g.merge_layout - 3 -} - -///| -fn graph_num_expansion_rows(g : GitGraph) -> Int { - graph_num_dashed_parents(g) * 2 -} - -///| -fn graph_needs_pre_commit_line(g : GitGraph) -> Bool { - g.num_parents >= 3 && - g.commit_index < g.num_columns - 1 && - g.expansion_row < graph_num_expansion_rows(g) -} - -///| -fn graph_is_mapping_correct(g : GitGraph) -> Bool { - for i in 0.. Unit { - let mut i = graph_find_new_column_by_commit(g, commit) - let mut mapping_idx = 0 - if i < 0 { - i = g.num_new_columns - g.new_columns[i] = commit - g.num_new_columns += 1 - } - if g.num_parents > 1 && idx > -1 && g.merge_layout == -1 { - let dist = idx - i - let shift = if dist > 1 { 2 * dist - 3 } else { 1 } - g.merge_layout = if dist > 0 { 0 } else { 1 } - g.edges_added = g.num_parents + g.merge_layout - 2 - mapping_idx = g.width + (g.merge_layout - 1) * shift - g.width += 2 * g.merge_layout - } else if g.edges_added > 0 && - g.width >= 2 && - i == g.mapping[g.width - 2] { - mapping_idx = g.width - 2 - g.edges_added = -1 - } else { - mapping_idx = g.width - g.width += 2 - } - g.mapping[mapping_idx] = i -} - -///| Port of git's graph_update_columns(). -fn graph_update_columns(g : GitGraph) -> Unit { - let tmp = g.columns - g.columns = g.new_columns - g.new_columns = tmp - g.num_columns = g.num_new_columns - g.num_new_columns = 0 - let max_new_columns = g.num_columns + g.num_parents - graph_ensure_capacity(g, max_new_columns) - g.mapping_size = 2 * max_new_columns - for i in 0.. 0 { - let max_width = g.graph_max_lanes * 2 + 2 - if g.width > max_width { - g.width = max_width - } - } - while g.mapping_size > 1 && g.mapping[g.mapping_size - 1] < 0 { - g.mapping_size -= 1 - } -} - -///| Port of git's graph_update(): advance the graph to the next commit. -fn graph_update( - g : GitGraph, - commit_hex : String, - is_boundary : Bool, - parents : Array[String], -) -> Unit { - g.commit = commit_hex - g.commit_is_boundary = is_boundary - g.cur_parents = graph_interesting_parents(g, parents) - g.num_parents = g.cur_parents.length() - g.prev_commit_index = g.commit_index - graph_update_columns(g) - g.expansion_row = 0 - if g.state != graph_state_padding { - g.state = graph_state_skip - } else if graph_needs_pre_commit_line(g) { - g.state = graph_state_pre_commit - } else { - g.state = graph_state_commit - } -} - -///| -fn graph_pad_horizontally(g : GitGraph, line : Array[Char]) -> Unit { - while line.length() < g.width { - line.push(' ') - } -} - -///| -fn graph_chars_to_string(line : Array[Char]) -> String { - let sb = StringBuilder::new() - for c in line { - sb.write_char(c) - } - sb.to_string() -} - -///| -fn graph_output_padding_line(g : GitGraph, line : Array[Char]) -> Unit { - let mut i = 0 - while i < g.num_new_columns { - if graph_needs_truncation(g, i) { - line.push('~') - line.push(' ') - break - } - line.push('|') - line.push(' ') - i += 1 - } -} - -///| -fn graph_output_skip_line(g : GitGraph, line : Array[Char]) -> Unit { - line.push('.') - line.push('.') - line.push('.') - if graph_needs_pre_commit_line(g) { - graph_update_state(g, graph_state_pre_commit) - } else { - graph_update_state(g, graph_state_commit) - } -} - -///| -fn graph_output_pre_commit_line(g : GitGraph, line : Array[Char]) -> Unit { - let mut seen_this = false - let mut i = 0 - while i < g.num_columns { - if g.columns[i] == g.commit { - seen_this = true - line.push('|') - for _ in 0.. 0 { - line.push('\\') - } else { - line.push('|') - } - line.push(' ') - i += 1 - } - g.expansion_row += 1 - if !graph_needs_pre_commit_line(g) { - graph_update_state(g, graph_state_commit) - } -} - -///| -fn graph_output_commit_char(g : GitGraph, line : Array[Char]) -> Unit { - if g.commit_is_boundary { - line.push('o') - } else { - line.push('*') - } -} - -///| Port of git's graph_draw_octopus_merge(). -fn graph_draw_octopus_merge(g : GitGraph, line : Array[Char]) -> Unit { - let dashed = graph_num_dashed_parents(g) - let mut i = 0 - while i < dashed { - line.push('-') - // Commit is at commit_index; each iteration moves one lane to the right. - if graph_needs_truncation(g, g.commit_index + 1 + i) { - line.push('~') - line.push(' ') - break - } - line.push(if i == dashed - 1 { '.' } else { '-' }) - i += 1 - } -} - -///| Port of git's graph_output_commit_line(). -fn graph_output_commit_line(g : GitGraph, line : Array[Char]) -> Unit { - let mut seen_this = false - let mut i = 0 - while i <= g.num_columns { - let col_commit = if i == g.num_columns { - if seen_this { - break - } - g.commit - } else { - g.columns[i] - } - if col_commit == g.commit { - seen_this = true - graph_output_commit_char(g, line) - if graph_needs_truncation(g, i) { - line.push(' ') - break - } - if g.num_parents > 2 { - graph_draw_octopus_merge(g, line) - } - } else if graph_needs_truncation(g, i) { - line.push('~') - line.push(' ') - seen_this = true - break - } else if seen_this && g.edges_added > 1 { - line.push('\\') - } else if seen_this && g.edges_added == 1 { - if g.prev_state == graph_state_post_merge && - g.prev_edges_added > 0 && - g.prev_commit_index < i { - line.push('\\') - } else { - line.push('|') - } - } else if g.prev_state == graph_state_collapsing && - g.old_mapping[2 * i + 1] == i && - g.mapping[2 * i] < i { - line.push('/') - } else { - line.push('|') - } - line.push(' ') - i += 1 - } - if g.num_parents > 1 { - // If the commit is over the truncation limit but its first parent is on a - // visible lane, we still draw the (truncated) merge lane. If both commit - // and first parent are truncated, no merge lane is needed. - if !graph_needs_truncation(g, g.commit_index) { - graph_update_state(g, graph_state_post_merge) - } else { - let lane = graph_find_new_column_by_commit(g, g.cur_parents[0]) - if !graph_needs_truncation(g, lane) { - graph_update_state(g, graph_state_post_merge) - } else if graph_is_mapping_correct(g) { - graph_update_state(g, graph_state_padding) - } else { - graph_update_state(g, graph_state_collapsing) - } - } - } else if graph_is_mapping_correct(g) { - graph_update_state(g, graph_state_padding) - } else { - graph_update_state(g, graph_state_collapsing) - } -} - -///| Port of git's graph_output_post_merge_line(). -fn graph_output_post_merge_line(g : GitGraph, line : Array[Char]) -> Unit { - let mut seen_this = false - let first_parent_hex = g.cur_parents[0] - let mut parent_col_set = false - let mut i = 0 - while i <= g.num_columns { - let col_commit = if i == g.num_columns { - if seen_this { - break - } - g.commit - } else { - g.columns[i] - } - if col_commit == g.commit { - seen_this = true - let mut idx = g.merge_layout - let mut truncated = false - let mut j = 0 - while j < g.num_parents { - let par_hex = g.cur_parents[j] - let _par_column = graph_find_new_column_by_commit(g, par_hex) - line.push(graph_merge_chars[idx]) - // j counts parents; halve it to compare with the column index i. - // Don't truncate if there are no more lanes to print (end of lane). - if graph_needs_truncation(g, j / 2 + i) && j / 2 + i <= g.num_columns { - if (j + i * 2) % 2 != 0 { - line.push(' ') - } - line.push('~') - line.push(' ') - truncated = true - break - } - if idx == 2 { - // Check if the next lane needs truncation to avoid doubled padding. - if graph_needs_truncation(g, (j + 1) / 2 + i) && - j < g.num_parents - 1 { - line.push('~') - line.push(' ') - truncated = true - break - } else if g.edges_added > 0 || j < g.num_parents - 1 { - line.push(' ') - } - } else { - idx += 1 - } - j += 1 - } - if truncated { - break - } - if g.edges_added == 0 { - line.push(' ') - } - } else if graph_needs_truncation(g, i) { - line.push('~') - line.push(' ') - break - } else if seen_this { - if g.edges_added > 0 { - line.push('\\') - } else { - line.push('|') - } - // If it's between two lanes and the next would be truncated, don't pad. - if !graph_needs_truncation(g, i + 1) { - line.push(' ') - } - } else { - line.push('|') - if g.merge_layout != 0 || i != g.commit_index - 1 { - if parent_col_set { - line.push('_') - } else { - line.push(' ') - } - } - } - if col_commit == first_parent_hex { - parent_col_set = true - } - i += 1 - } - if graph_is_mapping_correct(g) { - graph_update_state(g, graph_state_padding) - } else { - graph_update_state(g, graph_state_collapsing) - } -} - -///| Port of git's graph_output_collapsing_line(). -fn graph_output_collapsing_line(g : GitGraph, line : Array[Char]) -> Unit { - let mut used_horizontal = false - let mut horizontal_edge = -1 - let mut horizontal_edge_target = -1 - let tmp = g.mapping - g.mapping = g.old_mapping - g.old_mapping = tmp - for i in 0.. (String, Bool) { - let line : Array[Char] = [] - let mut shown = false - if g.state == graph_state_padding { - graph_output_padding_line(g, line) - } else if g.state == graph_state_skip { - graph_output_skip_line(g, line) - } else if g.state == graph_state_pre_commit { - graph_output_pre_commit_line(g, line) - } else if g.state == graph_state_commit { - graph_output_commit_line(g, line) - shown = true - } else if g.state == graph_state_post_merge { - graph_output_post_merge_line(g, line) - } else { - graph_output_collapsing_line(g, line) - } - graph_pad_horizontally(g, line) - (graph_chars_to_string(line), shown) -} - -///| Port of git's graph_padding_line(): the inter-entry separator row. When the -/// current state is GRAPH_COMMIT this draws the special padding that keeps merge -/// columns aligned; otherwise it falls through to graph_next_line(). -fn graph_padding_line(g : GitGraph) -> String { - if g.state != graph_state_commit { - return graph_next_line(g).0 - } - let line : Array[Char] = [] - let mut i = 0 - while i < g.num_columns { - if graph_needs_truncation(g, i) { - line.push('~') - line.push(' ') - break - } - line.push('|') - if g.columns[i] == g.commit && g.num_parents > 2 { - let len = (g.num_parents - 2) * 2 - for _ in 0..calendar conversion, timezone offset +// parsing, and the git date output modes) extracted from log.mbt. The +// `format_date_by_mode` dispatcher in log.mbt selects among them. + +///| +fn format_timestamp_default(ts : Int64, tz : String) -> String { + let (year, month, day, hour, minute, second) = epoch_to_calendar(ts, tz) + let dow = day_of_week(year, month, day) + let dow_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + let month_names = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", + "Dec", + ] + let dn = dow_names[dow] + let mn = month_names[month - 1] + "\{dn} \{mn} \{day} \{pad2(hour)}:\{pad2(minute)}:\{pad2(second)} \{year} \{tz}" +} + +///| +fn format_timestamp_default_notz(ts : Int64, tz : String) -> String { + let (year, month, day, hour, minute, second) = epoch_to_calendar(ts, tz) + let dow = day_of_week(year, month, day) + let dow_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + let month_names = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", + "Dec", + ] + let dn = dow_names[dow] + let mn = month_names[month - 1] + "\{dn} \{mn} \{day} \{pad2(hour)}:\{pad2(minute)}:\{pad2(second)} \{year}" +} + +///| +fn format_timestamp_short(ts : Int64, tz : String) -> String { + let (year, month, day, _, _, _) = epoch_to_calendar(ts, tz) + "\{year}-\{pad2(month)}-\{pad2(day)}" +} + +///| +fn format_timestamp_iso_strict(ts : Int64, tz : String) -> String { + let (year, month, day, hour, minute, second) = epoch_to_calendar(ts, tz) + let tz_formatted = if tz.length() >= 5 { + String::unsafe_substring(tz, start=0, end=3) + + ":" + + String::unsafe_substring(tz, start=3, end=5) + } else { + tz + } + "\{year}-\{pad2(month)}-\{pad2(day)}T\{pad2(hour)}:\{pad2(minute)}:\{pad2(second)}\{tz_formatted}" +} + +///| +fn format_timestamp_iso(ts : Int64, tz : String) -> String { + let (year, month, day, hour, minute, second) = epoch_to_calendar(ts, tz) + let y = year.to_string() + let mo = pad2(month) + let d = pad2(day) + let h = pad2(hour) + let mi = pad2(minute) + let s = pad2(second) + "\{y}-\{mo}-\{d} \{h}:\{mi}:\{s} \{tz}" +} + +///| +fn format_timestamp_rfc2822(ts : Int64, tz : String) -> String { + let (year, month, day, hour, minute, second) = epoch_to_calendar(ts, tz) + let dow = day_of_week(year, month, day) + let dow_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + let month_names = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", + "Dec", + ] + let dn = dow_names[dow] + let mn = month_names[month - 1] + let d = day.to_string() + let y = year.to_string() + let h = pad2(hour) + let mi = pad2(minute) + let s = pad2(second) + "\{dn}, \{d} \{mn} \{y} \{h}:\{mi}:\{s} \{tz}" +} + +///| +fn pad2(n : Int) -> String { + if n < 10 { + "0" + n.to_string() + } else { + n.to_string() + } +} + +///| +fn epoch_to_calendar(ts : Int64, tz : String) -> (Int, Int, Int, Int, Int, Int) { + // Parse timezone offset + let tz_offset_seconds = parse_tz_offset(tz) + let adjusted = ts + tz_offset_seconds.to_int64() + // Convert epoch seconds to y/m/d h:m:s + let mut days = (adjusted / 86400L).to_int() + let mut remaining = (adjusted % 86400L).to_int() + if remaining < 0 { + days -= 1 + remaining += 86400 + } + let hour = remaining / 3600 + remaining = remaining % 3600 + let minute = remaining / 60 + let second = remaining % 60 + // Days since 1970-01-01 + let (year, month, day) = log_days_to_date(days) + (year, month, day, hour, minute, second) +} + +///| +fn parse_tz_offset(tz : String) -> Int { + if tz.length() < 5 { + return 0 + } + let chars = tz.to_array() + let sign = if chars[0] == '-' { -1 } else { 1 } + let h = (chars[1].to_int() - '0'.to_int()) * 10 + + (chars[2].to_int() - '0'.to_int()) + let m = (chars[3].to_int() - '0'.to_int()) * 10 + + (chars[4].to_int() - '0'.to_int()) + sign * (h * 3600 + m * 60) +} + +///| +fn log_days_to_date(days_since_epoch : Int) -> (Int, Int, Int) { + // Algorithm from http://howardhinnant.github.io/date_algorithms.html + let z = days_since_epoch + 719468 + let era = (if z >= 0 { z } else { z - 146096 }) / 146097 + let doe = z - era * 146097 + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365 + let y = yoe + era * 400 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100) + let mp = (5 * doy + 2) / 153 + let d = doy - (153 * mp + 2) / 5 + 1 + let m = mp + (if mp < 10 { 3 } else { -9 }) + let year = y + (if m <= 2 { 1 } else { 0 }) + (year, m, d) +} + +///| +fn day_of_week(year : Int, month : Int, day : Int) -> Int { + // Tomohiko Sakamoto's algorithm (0=Sunday) + let t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4] + let y = if month < 3 { year - 1 } else { year } + (y + y / 4 - y / 100 + y / 400 + t[month - 1] + day) % 7 +} diff --git a/modules/bit/src/cmd/bit/log_graph.mbt b/modules/bit/src/cmd/bit/log_graph.mbt new file mode 100644 index 00000000..6a95ac14 --- /dev/null +++ b/modules/bit/src/cmd/bit/log_graph.mbt @@ -0,0 +1,714 @@ +// Graph rendering for `bit log --graph`. This is a faithful port of git's +// graph.c state machine, extracted from log.mbt to keep that file focused on +// argument parsing, history walking, and commit formatting. The renderer is +// driven by `log_emit_commit_graph` (in log.mbt), which feeds the already +// topo-ordered commit list through this state machine. + +// Graph renderer state constants — a faithful port of git's `enum graph_state` +// (graph.c). The algorithm below mirrors graph.c so that `bit log --graph` +// output is byte-for-byte identical to git's for arbitrary topologies. +let graph_state_padding = 0 + +///| +let graph_state_skip = 1 + +///| +let graph_state_pre_commit = 2 + +///| +let graph_state_commit = 3 + +///| +let graph_state_post_merge = 4 + +///| +let graph_state_collapsing = 5 + +///| merge_chars[] from graph.c: index 0 = '/', 1 = '|', 2 = '\\'. +let graph_merge_chars : Array[Char] = ['/', '|', '\\'] + +///| Port of git's `struct git_graph` (graph.c). Columns hold commit hexes ("" +/// means an unused slot); colors are omitted since `bit log` never colorizes +/// the graph. See third_party/git/graph.c for the field documentation. +struct GitGraph { + mut commit : String + mut commit_is_boundary : Bool + mut num_parents : Int + mut width : Int + mut expansion_row : Int + mut state : Int + mut prev_state : Int + mut commit_index : Int + mut prev_commit_index : Int + mut merge_layout : Int + mut edges_added : Int + mut prev_edges_added : Int + mut num_columns : Int + mut num_new_columns : Int + mut mapping_size : Int + mut columns : Array[String] + mut new_columns : Array[String] + mut mapping : Array[Int] + mut old_mapping : Array[Int] + mut cur_parents : Array[String] + shown : Map[String, Bool] + first_parent_only : Bool + // --graph-lane-limit=: cap the number of visible lanes; lanes at or + // beyond this index are replaced with a "~" truncation mark. 0 (or any + // value <= 0) means no limit. Mirrors git's revs->graph_max_lanes. + graph_max_lanes : Int +} + +///| Port of git's graph_needs_truncation(): true when `lane` is at or beyond +/// the configured lane limit. Values <= 0 mean no limit. +fn graph_needs_truncation(g : GitGraph, lane : Int) -> Bool { + g.graph_max_lanes > 0 && lane >= g.graph_max_lanes +} + +///| +fn GitGraph::new( + shown : Map[String, Bool], + first_parent_only : Bool, + graph_max_lanes : Int, +) -> GitGraph { + { + commit: "", + commit_is_boundary: false, + num_parents: 0, + width: 0, + expansion_row: 0, + state: graph_state_padding, + prev_state: graph_state_padding, + commit_index: 0, + prev_commit_index: 0, + merge_layout: 0, + edges_added: 0, + prev_edges_added: 0, + num_columns: 0, + num_new_columns: 0, + mapping_size: 0, + columns: [], + new_columns: [], + mapping: [], + old_mapping: [], + cur_parents: [], + shown, + first_parent_only, + graph_max_lanes, + } +} + +///| A parent is "interesting" (gets a graph lane drawn to it) if it is part of +/// the walk — i.e. present in the full reachable `shown` set. This mirrors git's +/// graph_is_interesting(), which keys off the revision walk rather than the `-n` +/// output limit: the walk gathers the whole history, so a merge parent just past +/// an `-n` cutoff is still interesting (its lane is kept), while a parent that +/// was never walked (missing/grafted/excluded) is not, so no phantom lane is +/// drawn. With `--first-parent`, only the first parent is ever interesting. +fn graph_interesting_parents( + g : GitGraph, + parents : Array[String], +) -> Array[String] { + if parents.length() == 0 { + return [] + } + if g.first_parent_only { + if g.shown.get(parents[0]).unwrap_or(false) { + return [parents[0]] + } + return [] + } + let res : Array[String] = [] + for p in parents { + if g.shown.get(p).unwrap_or(false) { + res.push(p) + } + } + res +} + +///| +fn graph_update_state(g : GitGraph, s : Int) -> Unit { + g.prev_state = g.state + g.state = s +} + +///| +fn graph_ensure_capacity(g : GitGraph, n : Int) -> Unit { + while g.columns.length() < n { + g.columns.push("") + } + while g.new_columns.length() < n { + g.new_columns.push("") + } + let m = 2 * n + 2 + while g.mapping.length() < m { + g.mapping.push(-1) + } + while g.old_mapping.length() < m { + g.old_mapping.push(-1) + } +} + +///| +fn graph_find_new_column_by_commit(g : GitGraph, commit : String) -> Int { + for i in 0.. Int { + g.num_parents + g.merge_layout - 3 +} + +///| +fn graph_num_expansion_rows(g : GitGraph) -> Int { + graph_num_dashed_parents(g) * 2 +} + +///| +fn graph_needs_pre_commit_line(g : GitGraph) -> Bool { + g.num_parents >= 3 && + g.commit_index < g.num_columns - 1 && + g.expansion_row < graph_num_expansion_rows(g) +} + +///| +fn graph_is_mapping_correct(g : GitGraph) -> Bool { + for i in 0.. Unit { + let mut i = graph_find_new_column_by_commit(g, commit) + let mut mapping_idx = 0 + if i < 0 { + i = g.num_new_columns + g.new_columns[i] = commit + g.num_new_columns += 1 + } + if g.num_parents > 1 && idx > -1 && g.merge_layout == -1 { + let dist = idx - i + let shift = if dist > 1 { 2 * dist - 3 } else { 1 } + g.merge_layout = if dist > 0 { 0 } else { 1 } + g.edges_added = g.num_parents + g.merge_layout - 2 + mapping_idx = g.width + (g.merge_layout - 1) * shift + g.width += 2 * g.merge_layout + } else if g.edges_added > 0 && + g.width >= 2 && + i == g.mapping[g.width - 2] { + mapping_idx = g.width - 2 + g.edges_added = -1 + } else { + mapping_idx = g.width + g.width += 2 + } + g.mapping[mapping_idx] = i +} + +///| Port of git's graph_update_columns(). +fn graph_update_columns(g : GitGraph) -> Unit { + let tmp = g.columns + g.columns = g.new_columns + g.new_columns = tmp + g.num_columns = g.num_new_columns + g.num_new_columns = 0 + let max_new_columns = g.num_columns + g.num_parents + graph_ensure_capacity(g, max_new_columns) + g.mapping_size = 2 * max_new_columns + for i in 0.. 0 { + let max_width = g.graph_max_lanes * 2 + 2 + if g.width > max_width { + g.width = max_width + } + } + while g.mapping_size > 1 && g.mapping[g.mapping_size - 1] < 0 { + g.mapping_size -= 1 + } +} + +///| Port of git's graph_update(): advance the graph to the next commit. +fn graph_update( + g : GitGraph, + commit_hex : String, + is_boundary : Bool, + parents : Array[String], +) -> Unit { + g.commit = commit_hex + g.commit_is_boundary = is_boundary + g.cur_parents = graph_interesting_parents(g, parents) + g.num_parents = g.cur_parents.length() + g.prev_commit_index = g.commit_index + graph_update_columns(g) + g.expansion_row = 0 + if g.state != graph_state_padding { + g.state = graph_state_skip + } else if graph_needs_pre_commit_line(g) { + g.state = graph_state_pre_commit + } else { + g.state = graph_state_commit + } +} + +///| +fn graph_pad_horizontally(g : GitGraph, line : Array[Char]) -> Unit { + while line.length() < g.width { + line.push(' ') + } +} + +///| +fn graph_chars_to_string(line : Array[Char]) -> String { + let sb = StringBuilder::new() + for c in line { + sb.write_char(c) + } + sb.to_string() +} + +///| +fn graph_output_padding_line(g : GitGraph, line : Array[Char]) -> Unit { + let mut i = 0 + while i < g.num_new_columns { + if graph_needs_truncation(g, i) { + line.push('~') + line.push(' ') + break + } + line.push('|') + line.push(' ') + i += 1 + } +} + +///| +fn graph_output_skip_line(g : GitGraph, line : Array[Char]) -> Unit { + line.push('.') + line.push('.') + line.push('.') + if graph_needs_pre_commit_line(g) { + graph_update_state(g, graph_state_pre_commit) + } else { + graph_update_state(g, graph_state_commit) + } +} + +///| +fn graph_output_pre_commit_line(g : GitGraph, line : Array[Char]) -> Unit { + let mut seen_this = false + let mut i = 0 + while i < g.num_columns { + if g.columns[i] == g.commit { + seen_this = true + line.push('|') + for _ in 0.. 0 { + line.push('\\') + } else { + line.push('|') + } + line.push(' ') + i += 1 + } + g.expansion_row += 1 + if !graph_needs_pre_commit_line(g) { + graph_update_state(g, graph_state_commit) + } +} + +///| +fn graph_output_commit_char(g : GitGraph, line : Array[Char]) -> Unit { + if g.commit_is_boundary { + line.push('o') + } else { + line.push('*') + } +} + +///| Port of git's graph_draw_octopus_merge(). +fn graph_draw_octopus_merge(g : GitGraph, line : Array[Char]) -> Unit { + let dashed = graph_num_dashed_parents(g) + let mut i = 0 + while i < dashed { + line.push('-') + // Commit is at commit_index; each iteration moves one lane to the right. + if graph_needs_truncation(g, g.commit_index + 1 + i) { + line.push('~') + line.push(' ') + break + } + line.push(if i == dashed - 1 { '.' } else { '-' }) + i += 1 + } +} + +///| Port of git's graph_output_commit_line(). +fn graph_output_commit_line(g : GitGraph, line : Array[Char]) -> Unit { + let mut seen_this = false + let mut i = 0 + while i <= g.num_columns { + let col_commit = if i == g.num_columns { + if seen_this { + break + } + g.commit + } else { + g.columns[i] + } + if col_commit == g.commit { + seen_this = true + graph_output_commit_char(g, line) + if graph_needs_truncation(g, i) { + line.push(' ') + break + } + if g.num_parents > 2 { + graph_draw_octopus_merge(g, line) + } + } else if graph_needs_truncation(g, i) { + line.push('~') + line.push(' ') + seen_this = true + break + } else if seen_this && g.edges_added > 1 { + line.push('\\') + } else if seen_this && g.edges_added == 1 { + if g.prev_state == graph_state_post_merge && + g.prev_edges_added > 0 && + g.prev_commit_index < i { + line.push('\\') + } else { + line.push('|') + } + } else if g.prev_state == graph_state_collapsing && + g.old_mapping[2 * i + 1] == i && + g.mapping[2 * i] < i { + line.push('/') + } else { + line.push('|') + } + line.push(' ') + i += 1 + } + if g.num_parents > 1 { + // If the commit is over the truncation limit but its first parent is on a + // visible lane, we still draw the (truncated) merge lane. If both commit + // and first parent are truncated, no merge lane is needed. + if !graph_needs_truncation(g, g.commit_index) { + graph_update_state(g, graph_state_post_merge) + } else { + let lane = graph_find_new_column_by_commit(g, g.cur_parents[0]) + if !graph_needs_truncation(g, lane) { + graph_update_state(g, graph_state_post_merge) + } else if graph_is_mapping_correct(g) { + graph_update_state(g, graph_state_padding) + } else { + graph_update_state(g, graph_state_collapsing) + } + } + } else if graph_is_mapping_correct(g) { + graph_update_state(g, graph_state_padding) + } else { + graph_update_state(g, graph_state_collapsing) + } +} + +///| Port of git's graph_output_post_merge_line(). +fn graph_output_post_merge_line(g : GitGraph, line : Array[Char]) -> Unit { + let mut seen_this = false + let first_parent_hex = g.cur_parents[0] + let mut parent_col_set = false + let mut i = 0 + while i <= g.num_columns { + let col_commit = if i == g.num_columns { + if seen_this { + break + } + g.commit + } else { + g.columns[i] + } + if col_commit == g.commit { + seen_this = true + let mut idx = g.merge_layout + let mut truncated = false + let mut j = 0 + while j < g.num_parents { + let par_hex = g.cur_parents[j] + let _par_column = graph_find_new_column_by_commit(g, par_hex) + line.push(graph_merge_chars[idx]) + // j counts parents; halve it to compare with the column index i. + // Don't truncate if there are no more lanes to print (end of lane). + if graph_needs_truncation(g, j / 2 + i) && j / 2 + i <= g.num_columns { + if (j + i * 2) % 2 != 0 { + line.push(' ') + } + line.push('~') + line.push(' ') + truncated = true + break + } + if idx == 2 { + // Check if the next lane needs truncation to avoid doubled padding. + if graph_needs_truncation(g, (j + 1) / 2 + i) && + j < g.num_parents - 1 { + line.push('~') + line.push(' ') + truncated = true + break + } else if g.edges_added > 0 || j < g.num_parents - 1 { + line.push(' ') + } + } else { + idx += 1 + } + j += 1 + } + if truncated { + break + } + if g.edges_added == 0 { + line.push(' ') + } + } else if graph_needs_truncation(g, i) { + line.push('~') + line.push(' ') + break + } else if seen_this { + if g.edges_added > 0 { + line.push('\\') + } else { + line.push('|') + } + // If it's between two lanes and the next would be truncated, don't pad. + if !graph_needs_truncation(g, i + 1) { + line.push(' ') + } + } else { + line.push('|') + if g.merge_layout != 0 || i != g.commit_index - 1 { + if parent_col_set { + line.push('_') + } else { + line.push(' ') + } + } + } + if col_commit == first_parent_hex { + parent_col_set = true + } + i += 1 + } + if graph_is_mapping_correct(g) { + graph_update_state(g, graph_state_padding) + } else { + graph_update_state(g, graph_state_collapsing) + } +} + +///| Port of git's graph_output_collapsing_line(). +fn graph_output_collapsing_line(g : GitGraph, line : Array[Char]) -> Unit { + let mut used_horizontal = false + let mut horizontal_edge = -1 + let mut horizontal_edge_target = -1 + let tmp = g.mapping + g.mapping = g.old_mapping + g.old_mapping = tmp + for i in 0.. (String, Bool) { + let line : Array[Char] = [] + let mut shown = false + if g.state == graph_state_padding { + graph_output_padding_line(g, line) + } else if g.state == graph_state_skip { + graph_output_skip_line(g, line) + } else if g.state == graph_state_pre_commit { + graph_output_pre_commit_line(g, line) + } else if g.state == graph_state_commit { + graph_output_commit_line(g, line) + shown = true + } else if g.state == graph_state_post_merge { + graph_output_post_merge_line(g, line) + } else { + graph_output_collapsing_line(g, line) + } + graph_pad_horizontally(g, line) + (graph_chars_to_string(line), shown) +} + +///| Port of git's graph_padding_line(): the inter-entry separator row. When the +/// current state is GRAPH_COMMIT this draws the special padding that keeps merge +/// columns aligned; otherwise it falls through to graph_next_line(). +fn graph_padding_line(g : GitGraph) -> String { + if g.state != graph_state_commit { + return graph_next_line(g).0 + } + let line : Array[Char] = [] + let mut i = 0 + while i < g.num_columns { + if graph_needs_truncation(g, i) { + line.push('~') + line.push(' ') + break + } + line.push('|') + if g.columns[i] == g.commit && g.num_parents > 2 { + let len = (g.num_parents - 2) * 2 + for _ in 0..