gwm-cli 1.6.0

git worktree manager — TUI + CLI, native libgit2, per-repo bootstrap
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
//! Per-row commit graph renderer for the Recent Commits sidebar block.
//!
//! Direct Rust port of lazygit's `pkg/gui/presentation/graph/` package
//! (`graph.go` + `cell.go`). The algorithm walks the commit list once,
//! maintains a set of active "pipes" (vertical line segments) between
//! consecutive rows, and renders each row as a fixed sequence of 2-char
//! cells (`<glyph><filler>`).
//!
//! The output is a `Vec<Span<'static>>` per row (from
//! [`render_pipe_set`]) or a `Vec<Vec<Span<'static>>>` for the full
//! commit list (from [`render_commits`]). Callers assemble those spans
//! into a `ratatui::text::Line` together with the rest of the row
//! (hash, author initials, subject) — see
//! `src/tui/ui.rs::commit_row_line`. Each row's spans cover exactly
//! `2 * (max_pos + 1)` cells, independent of terminal width — the
//! graph is width-deterministic on the input commit list (lazygit
//! caches on `(head_hash, count)` only).
//!
//! Differences from lazygit, all intentional:
//!
//! - **Single `branch`-role palette** — the theme's `branch` role for
//!   connectors and `branch` + bold for `○` / `◎` nodes (a flat
//!   `Color::Green` before the #170 theme audit). Lazygit uses per-author
//!   MD5→HSL→RGB colours; we skip that for now — every commit on `gwm`
//!   is authored by the same person, so the rainbow is wasted ink. The
//!   green matches the `Worktree` block's "synced" status badge for
//!   visual consistency across the sidebar.
//! - **No selected-commit override** — lazygit highlights the pipes
//!   originating from the cursor; our sidebar's selection lives on the
//!   *worktree* list, not on a specific commit.
//! - **Zero OID empty-tree sentinel** — lazygit targets a synthetic
//!   `EmptyTreeCommitHash` from the first commit's `Starts` pipe so
//!   `○` doesn't look orphaned. gwm uses `0000000000000000000000000000000000000000`
//!   for the first parent of a root commit; the rendered cell is still a
//!   plain `○` because the seeded sentinel pipe from row 0 terminates
//!   on it. The trivial commit-on-commit `Terminates` (where
//!   `from_pos == to_pos == commit_pos`) is skipped in
//!   [`render_pipe_set`] so it doesn't overwrite the node glyph.

use super::theme::Theme;
use crate::worktree::CommitRow;
use git2::Oid;
use ratatui::{
  style::{Modifier, Style},
  text::Span,
};
use std::collections::HashSet;

/// Lifecycle of a pipe across the row it sits in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PipeKind {
  /// A pipe arriving from above that ends on the current row's commit.
  Terminates,
  /// A pipe starting from the current row's commit, heading downward
  /// toward one of its parents.
  Starts,
  /// A pipe that arrived from above and continues below — passing
  /// through the current row, possibly shifting columns left/right.
  Continues,
}

#[derive(Debug, Clone)]
pub struct Pipe {
  pub from_pos: i16,
  pub to_pos: i16,
  pub from_hash: Oid,
  pub to_hash: Oid,
  pub kind: PipeKind,
}

impl Pipe {
  #[inline]
  fn left(&self) -> i16 {
    self.from_pos.min(self.to_pos)
  }
  #[inline]
  fn right(&self) -> i16 {
    self.from_pos.max(self.to_pos)
  }
}

/// What a cell represents at render time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CellType {
  Connection,
  Commit,
  Merge,
}

#[derive(Debug, Clone)]
struct Cell {
  up: bool,
  down: bool,
  left: bool,
  right: bool,
  cell_type: CellType,
}

impl Cell {
  fn new_connection() -> Self {
    Self {
      up: false,
      down: false,
      left: false,
      right: false,
      cell_type: CellType::Connection,
    }
  }
  fn set_up(&mut self) {
    self.up = true;
  }
  fn set_down(&mut self) {
    self.down = true;
  }
  fn set_left(&mut self) {
    self.left = true;
  }
  fn set_right(&mut self) {
    self.right = true;
  }
  fn set_type(&mut self, t: CellType) {
    self.cell_type = t;
  }
}

/// Port of lazygit's 16-case `getBoxDrawingChars` truth table
/// (`cell.go:147-183`). Returns `(glyph, right_filler)` — each cell emits
/// two characters so a continuous horizontal stroke can be drawn by
/// chaining `─` fillers across columns.
///
/// Glyphs are all in the U+2500 light-box-drawing block.
pub fn box_drawing_chars(up: bool, down: bool, left: bool, right: bool) -> (char, char) {
  match (up, down, left, right) {
    (true, true, true, true) => ('', ''),
    (true, true, true, false) => ('', ' '),
    (true, true, false, true) => ('', ''),
    (true, true, false, false) => ('', ' '),
    (true, false, true, true) => ('', ''),
    (true, false, true, false) => ('', ' '),
    (true, false, false, true) => ('', ''),
    (true, false, false, false) => ('', ' '),
    (false, true, true, true) => ('', ''),
    (false, true, true, false) => ('', ' '),
    (false, true, false, true) => ('', ''),
    (false, true, false, false) => ('', ' '),
    (false, false, true, true) => ('', ''),
    (false, false, true, false) => ('', ' '),
    (false, false, false, true) => ('', ''),
    (false, false, false, false) => (' ', ' '),
  }
}

/// Seed sentinel hash used for the pipe that ends on the very first
/// commit. Zero OID is outside normal `git log` output and keeps pipe
/// comparisons allocation-free.
fn start_hash() -> Oid {
  Oid::ZERO_SHA1
}

/// Walk `commits` once, producing the per-row pipe sets. This is the
/// Rust translation of lazygit's `GetPipeSets` (`graph.go:60-69`).
pub fn build_pipe_sets(commits: &[CommitRow]) -> Vec<Vec<Pipe>> {
  if commits.is_empty() {
    return Vec::new();
  }
  let mut pipes = vec![Pipe {
    from_pos: 0,
    to_pos: 0,
    from_hash: start_hash(),
    to_hash: commits[0].hash,
    kind: PipeKind::Starts,
  }];
  let mut out = Vec::with_capacity(commits.len());
  for commit in commits {
    pipes = get_next_pipes(&pipes, commit);
    out.push(pipes.clone());
  }
  out
}

/// Per-row transition. Port of lazygit's `getNextPipes`
/// (`graph.go:109-273`). Given the previous row's pipes and the current
/// commit, produces the current row's pipe set.
fn get_next_pipes(prev_pipes: &[Pipe], commit: &CommitRow) -> Vec<Pipe> {
  let max_pos: i16 = prev_pipes.iter().map(|p| p.to_pos).max().unwrap_or(0);

  // Pipes that terminated last row do not carry into this one.
  let current_pipes: Vec<&Pipe> = prev_pipes.iter().filter(|p| p.kind != PipeKind::Terminates).collect();

  // Default to a brand-new commit column. Falls back to the column of
  // any descendant pipe pointing at us.
  let mut pos: i16 = max_pos + 1;
  for pipe in &current_pipes {
    if pipe.to_hash == commit.hash {
      pos = pipe.to_pos;
      break;
    }
  }

  let mut new_pipes: Vec<Pipe> = Vec::with_capacity(current_pipes.len() + commit.parents.len());

  // Emit the STARTS pipe for the *first* parent (or empty-tree sentinel
  // when this is the root commit).
  let first_parent = commit.parents.first().copied().unwrap_or_else(start_hash);
  new_pipes.push(Pipe {
    from_pos: pos,
    to_pos: pos,
    from_hash: commit.hash,
    to_hash: first_parent,
    kind: PipeKind::Starts,
  });

  // Shared mutable state for the per-pipe loops below.
  let mut taken_spots: HashSet<i16> = HashSet::new();
  let mut traversed_spots: HashSet<i16> = HashSet::new();

  // Pre-compute the spots that continuing pipes already occupy — a new
  // merge-parent pipe must not land on top of them.
  let mut traversed_spots_for_continuing: HashSet<i16> = HashSet::new();
  for pipe in &current_pipes {
    if pipe.to_hash != commit.hash {
      traversed_spots_for_continuing.insert(pipe.to_pos);
    }
  }

  // Helper closures need shared borrows of the spot sets, so inline
  // them as plain `fn`-style helpers operating on locals here.
  fn next_free(spots: &HashSet<i16>) -> i16 {
    let mut i: i16 = 0;
    while spots.contains(&i) {
      i += 1;
    }
    i
  }
  fn next_free_for_new(taken: &HashSet<i16>, traversed_for_continuing: &HashSet<i16>) -> i16 {
    let mut i: i16 = 0;
    while taken.contains(&i) || traversed_for_continuing.contains(&i) {
      i += 1;
    }
    i
  }
  fn traverse(taken: &mut HashSet<i16>, traversed: &mut HashSet<i16>, from: i16, to: i16) {
    let (l, r) = if from <= to { (from, to) } else { (to, from) };
    for i in l..=r {
      traversed.insert(i);
    }
    taken.insert(to);
  }

  // Phase 1: terminating + leftward-continuing pipes from the previous row.
  for pipe in &current_pipes {
    if pipe.to_hash == commit.hash {
      // pipe ends on this commit
      new_pipes.push(Pipe {
        from_pos: pipe.to_pos,
        to_pos: pos,
        from_hash: pipe.from_hash,
        to_hash: pipe.to_hash,
        kind: PipeKind::Terminates,
      });
      traverse(&mut taken_spots, &mut traversed_spots, pipe.to_pos, pos);
    } else if pipe.to_pos < pos {
      // pipe continues; pick the next free column to its right
      let avail = next_free(&traversed_spots);
      new_pipes.push(Pipe {
        from_pos: pipe.to_pos,
        to_pos: avail,
        from_hash: pipe.from_hash,
        to_hash: pipe.to_hash,
        kind: PipeKind::Continues,
      });
      traverse(&mut taken_spots, &mut traversed_spots, pipe.to_pos, avail);
    }
  }

  // Phase 2: extra parents of a merge commit each open a new column.
  if commit.parents.len() >= 2 {
    for parent in commit.parents.iter().skip(1) {
      let avail = next_free_for_new(&taken_spots, &traversed_spots_for_continuing);
      new_pipes.push(Pipe {
        from_pos: pos,
        to_pos: avail,
        from_hash: commit.hash,
        to_hash: *parent,
        kind: PipeKind::Starts,
      });
      taken_spots.insert(avail);
    }
  }

  // Phase 3: continuing pipes from the *right* of the commit. They may
  // shift leftward to fill blank columns.
  for pipe in &current_pipes {
    if pipe.to_hash != commit.hash && pipe.to_pos > pos {
      let mut last = pipe.to_pos;
      let mut i = pipe.to_pos;
      while i > pos {
        i -= 1;
        if taken_spots.contains(&i) || traversed_spots.contains(&i) {
          break;
        }
        last = i;
      }
      new_pipes.push(Pipe {
        from_pos: pipe.to_pos,
        to_pos: last,
        from_hash: pipe.from_hash,
        to_hash: pipe.to_hash,
        kind: PipeKind::Continues,
      });
      traverse(&mut taken_spots, &mut traversed_spots, pipe.to_pos, last);
    }
  }

  // Stable ordering by to_pos, then kind (matches lazygit's sort).
  new_pipes.sort_by(|a, b| {
    if a.to_pos == b.to_pos {
      a.kind.cmp(&b.kind)
    } else {
      a.to_pos.cmp(&b.to_pos)
    }
  });

  new_pipes
}

/// Render a single pipe set into ratatui spans, two spans per cell. Port
/// of lazygit's `renderPipeSet` (`graph.go:275-385`).
pub fn render_pipe_set(pipes: &[Pipe], theme: &Theme) -> Vec<Span<'static>> {
  let mut max_pos: i16 = 0;
  let mut commit_pos: i16 = 0;
  let mut start_count: usize = 0;
  for pipe in pipes {
    if pipe.kind == PipeKind::Starts {
      start_count += 1;
      commit_pos = pipe.from_pos;
    } else if pipe.kind == PipeKind::Terminates {
      commit_pos = pipe.to_pos;
    }
    if pipe.right() > max_pos {
      max_pos = pipe.right();
    }
  }
  let is_merge = start_count > 1;

  let mut cells: Vec<Cell> = (0..=max_pos).map(|_| Cell::new_connection()).collect();

  // First pass: STARTS pipes paint their downward stroke + any leftward
  // continuation. Done first so subsequent passes can layer on top.
  for pipe in pipes {
    if pipe.kind == PipeKind::Starts {
      apply_pipe(&mut cells, pipe);
    }
  }
  // Second pass: TERMINATES and CONTINUES (except the trivial commit-
  // on-commit terminate that would erase the commit cell glyph).
  for pipe in pipes {
    if pipe.kind != PipeKind::Starts
      && !(pipe.kind == PipeKind::Terminates && pipe.from_pos == commit_pos && pipe.to_pos == commit_pos)
    {
      apply_pipe(&mut cells, pipe);
    }
  }

  // Mark the commit cell.
  if let Some(c) = cells.get_mut(commit_pos as usize) {
    c.set_type(if is_merge { CellType::Merge } else { CellType::Commit });
  }

  // The commit graph draws branch topology, so it follows the `branch`
  // role (pre-theme this was a flat `Color::Green`).
  let connector_style = Style::default().fg(theme.branch);
  let node_style = Style::default().fg(theme.branch).add_modifier(Modifier::BOLD);

  let mut out: Vec<Span<'static>> = Vec::with_capacity(cells.len() * 2);
  for cell in &cells {
    let (glyph, filler) = box_drawing_chars(cell.up, cell.down, cell.left, cell.right);
    let render_glyph: String = match cell.cell_type {
      CellType::Connection => glyph.to_string(),
      CellType::Commit => ''.to_string(),
      CellType::Merge => ''.to_string(),
    };
    let style = if matches!(cell.cell_type, CellType::Commit | CellType::Merge) {
      node_style
    } else {
      connector_style
    };
    out.push(Span::styled(render_glyph, style));
    // The right filler is always a connector (never a node), so it
    // keeps the connector style.
    out.push(Span::styled(filler.to_string(), connector_style));
  }
  out
}

fn apply_pipe(cells: &mut [Cell], pipe: &Pipe) {
  let left = pipe.left();
  let right = pipe.right();

  if left != right {
    for i in (left + 1)..right {
      if let Some(c) = cells.get_mut(i as usize) {
        c.set_left();
        c.set_right();
      }
    }
    if let Some(c) = cells.get_mut(left as usize) {
      c.set_right();
    }
    if let Some(c) = cells.get_mut(right as usize) {
      c.set_left();
    }
  }

  match pipe.kind {
    PipeKind::Starts | PipeKind::Continues => {
      if let Some(c) = cells.get_mut(pipe.to_pos as usize) {
        c.set_down();
      }
    }
    PipeKind::Terminates => {}
  }
  match pipe.kind {
    PipeKind::Terminates | PipeKind::Continues => {
      if let Some(c) = cells.get_mut(pipe.from_pos as usize) {
        c.set_up();
      }
    }
    PipeKind::Starts => {}
  }
}

/// One-shot helper: compute pipe sets for the commit list and render
/// each row's spans. Output length matches `commits.len()`.
pub fn render_commits(commits: &[CommitRow], theme: &Theme) -> Vec<Vec<Span<'static>>> {
  build_pipe_sets(commits)
    .into_iter()
    .map(|pipes| render_pipe_set(&pipes, theme))
    .collect()
}

/// Convenience constructor for tests — builds a `CommitRow` with the
/// minimum data the graph algorithm needs.
#[doc(hidden)]
pub fn test_row(hash: &str, parents: &[&str]) -> CommitRow {
  fn oid(label: &str) -> Oid {
    if label.len() == 40 {
      return Oid::from_str(label).expect("test hash must be valid hex");
    }
    let padded = format!("{:0>40}", label);
    Oid::from_str(&padded).expect("test hash label must be valid hex")
  }

  CommitRow {
    hash: oid(hash),
    author: String::new(),
    parents: parents.iter().map(|s| oid(s)).collect(),
    subject: String::new(),
  }
}