Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .restyled.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
restylers:
- pyment:
enabled: false
- rustfmt:
arguments: ["--edition=2024"]
- "*"
4 changes: 0 additions & 4 deletions BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ haskell_library(
"src/GitHub/Types/Base/*.hs",
"src/GitHub/Types/Base*.hs",
]),
ghcopts = ["-j4"],
src_strip_prefix = "src",
tags = [
"haskell",
Expand All @@ -34,7 +33,6 @@ haskell_library(
"src/GitHub/Types/Events/*.hs",
"src/GitHub/Types/Event*.hs",
]),
ghcopts = ["-j4"],
src_strip_prefix = "src",
tags = [
"haskell",
Expand Down Expand Up @@ -101,9 +99,7 @@ haskell_library(

hspec_test(
name = "testsuite",
size = "small",
args = [
"-j4",
"+RTS",
"-N4",
],
Expand Down
3 changes: 2 additions & 1 deletion stack.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
packages: [.]
resolver: lts-21.9
resolver: lts-21.25
extra-deps:
- Diff-1.0.2
- suspend-0.2.0.0
- timers-0.2.0.4
4 changes: 2 additions & 2 deletions tools/check-workflows.hs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,6 @@ showDiff a b = Text.pack . PP.render . toDoc $ diff
where
toDoc = Diff.prettyContextDiff (PP.text "payload")
(PP.text "value")
(PP.text . Text.unpack)
(\(Diff.Numbered _ t) -> PP.text . Text.unpack $ t)
diff = Diff.getContextDiff linesOfContext (Text.lines a) (Text.lines b)
linesOfContext = 3
linesOfContext = Just 3
100 changes: 100 additions & 0 deletions tools/gitui/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_clippy", "rust_library", "rust_test")

rust_library(
name = "gitui_lib",
srcs = [
"src/diff_utils.rs",
"src/engine/executor.rs",
"src/engine/git.rs",
"src/engine/mod.rs",
"src/engine/planner.rs",
"src/engine/topology.rs",
"src/engine/transaction.rs",
"src/engine/types.rs",
"src/lib.rs",
"src/patch_utils.rs",
"src/runtime.rs",
"src/split_state.rs",
"src/state/actions.rs",
"src/state/input.rs",
"src/state/mod.rs",
"src/state/reducer.rs",
"src/state/types.rs",
"src/testing.rs",
"src/topology/mod.rs",
"src/topology/virtual_layer.rs",
"src/ui/common.rs",
"src/ui/main_view.rs",
"src/ui/mod.rs",
"src/ui/preview_view.rs",
"src/ui/prompt_view.rs",
"src/ui/split_view.rs",
],
crate_name = "gitui",
edition = "2024",
visibility = ["//visibility:public"],
deps = [
"@crates//:anyhow",
"@crates//:crossterm",
"@crates//:git2",
"@crates//:indexmap",
"@crates//:itertools",
"@crates//:petgraph",
"@crates//:ratatui",
"@crates//:tempfile",
"@crates//:tokio",
"@crates//:unicode-segmentation",
],
)

rust_binary(
name = "gitui",
srcs = ["src/main.rs"],
edition = "2024",
rustc_flags = ["-Clink-arg=-fuse-ld=bfd"],
deps = [
":gitui_lib",
"@crates//:anyhow",
"@crates//:clap",
"@crates//:tokio",
],
)

TEST_SRCS = glob(["test/*.rs"])

[
rust_test(
name = test_file.replace("test/", "").replace(".rs", ""),
size = "small",
srcs = [test_file],
data = glob(["test/snapshots/**"]),
edition = "2024",
rustc_flags = ["-Clink-arg=-fuse-ld=bfd"],
deps = [
":gitui_lib",
"@crates//:anyhow",
"@crates//:crossterm",
"@crates//:git2",
"@crates//:insta",
"@crates//:petgraph",
"@crates//:proptest",
"@crates//:ratatui",
"@crates//:regex",
"@crates//:tempfile",
"@crates//:tokio",
],
)
for test_file in TEST_SRCS
]

rust_clippy(
name = "clippy",
testonly = True,
deps = [
":gitui",
":gitui_lib",
] + [
":" + src.replace("test/", "").replace(".rs", "")
for src in TEST_SRCS
],
)
111 changes: 111 additions & 0 deletions tools/gitui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Git Stack Manager (gitui)

A simple Git TUI for managing branch stacks and complex branch trees.

## Overview

This tool is designed to simplify the management of "stacked" branches, where
multiple feature branches are built on top of each other. It provides a visual
representation of the branch hierarchy and allows for easy restructuring of
entire subtrees.

## Key Features

- **Visual Branch Tree:** Automatically detects and displays the relationship
between local and remote branches.
- **Interactive Move:** "Grab" a branch and move it to a new parent. The tool
handles the rebase of the entire subtree.
- **Predictive Conflict Detection:** Highlights potential merge conflicts
*while* you are moving a branch, before any action is taken.
- **Heuristic Repair (`u`):** Automatically detects when a branch has drifted
from its true parent (e.g. after a remote rebase) and allows you to
"converge" it back with a single keypress.
- **Split Branch (`x`):** Interactively decompose a single commit into
multiple sequential branches by selecting specific hunks.
- **Remote Visibility:** Toggle between local-only and tracking views for
`origin` and `upstream` remotes.
- **Submit Workflow:** Plan and execute branch submissions to `upstream` with
automatic sync to `origin`.
- **Localize Remotes:** Easily create local tracking branches from remote
branches by simply moving them in the tree.
- **Branch Management:** Directly push (`p`), delete (`d`), reset (`r`),
rename (`R`), or amend (`m`/`M`) branches from the TUI.

## Shortcuts

### Navigation

- `j` / `Down`: Move selection down.
- `k` / `Up`: Move selection up.
- `a`: Toggle showing remote branches from `origin` and `upstream`.

### Manipulation

- `Space`: Grab or drop a branch. While grabbed, use `j`/`k` to select a new
parent, or `h` to move to root.
- `p`: Toggle pending push (for local branches with ahead commits).
- `s`: Toggle pending submit (push to `upstream`, delete from `origin`, merge
to `master`).
- `x`: Enter Split Branch mode (only available if 1 commit ahead of parent).
- `u`: Converge diverged branch (move to heuristic parent).
- `d`: Toggle pending delete.
- `r`: Toggle pending reset to upstream (or rebase onto upstream if
ahead/behind).
- `m`: Toggle pending amend (amends current staged changes into the selected
branch).
- `M`: Toggle pending amend with message update.
- `R`: Rename the selected branch.
- `f`: Toggle pending localize (for remote branches) or fetch (for root).

### Execution

- `v`: Enter Preview mode to see planned operations and predicted conflicts.
- `c`: Execute all pending operations.
- `Esc`: Cancel current grab or quit the current mode.
- `q`: Quit.

## CLI Usage

```bash
# Start the TUI in the current directory
gitui

# Start in a specific directory
gitui --path /path/to/repo

# Print the current tree and exit
gitui --tree

# Print the tree including remote branches
gitui --tree --all

# Show the submission plan for a branch and exit
gitui --submit branch-name

# Show the plan to fix a diverged branch (converge)
gitui --converge branch-name

# Show the plan to sync a branch with its upstream
gitui --sync branch-name
```

## Development

This tool is built with:

- **Language:** Rust
- **UI:** [Ratatui](https://ratatui.rs/)
- **Git Engine:** [git2-rs](https://github.com/rust-lang/git2-rs)
- **Build System:** Bazel

### Building

```bash
bazel build //hs-github-tools/tools/gitui:gitui
```

### Testing

```bash
bazel test //hs-github-tools/tools/gitui/...
```
135 changes: 135 additions & 0 deletions tools/gitui/src/diff_utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
use git2::{Diff, DiffFormat, DiffLineType};

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum LineType {
#[default]
Context,
Addition,
Deletion,
Header,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiffLine {
pub content: String,
pub line_type: LineType,
pub old_lineno: Option<u32>,
pub new_lineno: Option<u32>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Hunk {
pub header: String,
pub lines: Vec<DiffLine>,
pub old_start: u32,
pub old_lines: u32,
pub new_start: u32,
pub new_lines: u32,
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FileDiff {
pub path: String,
pub hunks: Vec<Hunk>,
}

pub fn parse_diff(diff: &Diff) -> anyhow::Result<Vec<FileDiff>> {
let mut file_diffs = Vec::new();
let mut current_file: Option<FileDiff> = None;
let mut current_hunk: Option<Hunk> = None;

diff.print(DiffFormat::Patch, |delta, hunk, line| {
let path_str = delta
.new_file()
.path()
.and_then(|p| p.to_str())
.unwrap_or("");

let is_different_file = match &current_file {
Some(f) => f.path != path_str,
None => true,
};

if is_different_file {
if let Some(h) = current_hunk.take()
&& let Some(ref mut f) = current_file
{
f.hunks.push(h);
}
if let Some(f) = current_file.take() {
file_diffs.push(f);
}
current_file = Some(FileDiff {
path: path_str.to_string(),
hunks: Vec::new(),
});
}

if let Some(h) = hunk {
let is_different_hunk = match &current_hunk {
Some(curr) => {
curr.old_start != h.old_start()
|| curr.old_lines != h.old_lines()
|| curr.new_start != h.new_start()
|| curr.new_lines != h.new_lines()
}
None => true,
};

if is_different_hunk {
if let Some(h_val) = current_hunk.take()
&& let Some(ref mut f) = current_file
{
f.hunks.push(h_val);
}

let header = std::str::from_utf8(h.header())
.unwrap_or("")
.trim()
.to_string();

current_hunk = Some(Hunk {
header,
lines: Vec::new(),
old_start: h.old_start(),
old_lines: h.old_lines(),
new_start: h.new_start(),
new_lines: h.new_lines(),
});
}
}

let line_type = match line.origin_value() {
DiffLineType::Context => LineType::Context,
DiffLineType::Addition => LineType::Addition,
DiffLineType::Deletion => LineType::Deletion,
_ => LineType::Header,
};

if line_type != LineType::Header
&& let Some(ref mut h_val) = current_hunk
{
h_val.lines.push(DiffLine {
content: std::str::from_utf8(line.content())
.unwrap_or("")
.to_string(),
line_type,
old_lineno: line.old_lineno(),
new_lineno: line.new_lineno(),
});
}

true
})?;

if let Some(h) = current_hunk
&& let Some(ref mut f) = current_file
{
f.hunks.push(h);
}
if let Some(f) = current_file {
file_diffs.push(f);
}

Ok(file_diffs)
}
Loading
Loading