Skip to content

Commit 3032a47

Browse files
committed
feat: Add a git TUI for managing lots of feature branches.
1 parent 50d7724 commit 3032a47

File tree

75 files changed

+17624
-8
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

75 files changed

+17624
-8
lines changed

BUILD.bazel

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ haskell_library(
1010
"src/GitHub/Types/Base/*.hs",
1111
"src/GitHub/Types/Base*.hs",
1212
]),
13-
ghcopts = ["-j4"],
1413
src_strip_prefix = "src",
1514
tags = [
1615
"haskell",
@@ -34,7 +33,6 @@ haskell_library(
3433
"src/GitHub/Types/Events/*.hs",
3534
"src/GitHub/Types/Event*.hs",
3635
]),
37-
ghcopts = ["-j4"],
3836
src_strip_prefix = "src",
3937
tags = [
4038
"haskell",
@@ -101,9 +99,7 @@ haskell_library(
10199

102100
hspec_test(
103101
name = "testsuite",
104-
size = "small",
105102
args = [
106-
"-j4",
107103
"+RTS",
108104
"-N4",
109105
],

tools/check-workflows.hs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,6 @@ showDiff a b = Text.pack . PP.render . toDoc $ diff
7878
where
7979
toDoc = Diff.prettyContextDiff (PP.text "payload")
8080
(PP.text "value")
81-
(PP.text . Text.unpack)
81+
(\(Diff.Numbered _ t) -> PP.text . Text.unpack $ t)
8282
diff = Diff.getContextDiff linesOfContext (Text.lines a) (Text.lines b)
83-
linesOfContext = 3
83+
linesOfContext = Just 3

tools/gitui/BUILD.bazel

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_clippy", "rust_library", "rust_test")
2+
3+
rust_library(
4+
name = "gitui_lib",
5+
srcs = [
6+
"src/diff_utils.rs",
7+
"src/engine/executor.rs",
8+
"src/engine/git.rs",
9+
"src/engine/mod.rs",
10+
"src/engine/planner.rs",
11+
"src/engine/topology.rs",
12+
"src/engine/transaction.rs",
13+
"src/engine/types.rs",
14+
"src/lib.rs",
15+
"src/patch_utils.rs",
16+
"src/runtime.rs",
17+
"src/split_state.rs",
18+
"src/state/actions.rs",
19+
"src/state/input.rs",
20+
"src/state/mod.rs",
21+
"src/state/reducer.rs",
22+
"src/state/types.rs",
23+
"src/testing.rs",
24+
"src/topology/mod.rs",
25+
"src/topology/virtual_layer.rs",
26+
"src/ui/common.rs",
27+
"src/ui/main_view.rs",
28+
"src/ui/mod.rs",
29+
"src/ui/preview_view.rs",
30+
"src/ui/prompt_view.rs",
31+
"src/ui/split_view.rs",
32+
],
33+
crate_name = "gitui",
34+
edition = "2024",
35+
visibility = ["//visibility:public"],
36+
deps = [
37+
"@crates//:anyhow",
38+
"@crates//:crossterm",
39+
"@crates//:git2",
40+
"@crates//:indexmap",
41+
"@crates//:itertools",
42+
"@crates//:petgraph",
43+
"@crates//:ratatui",
44+
"@crates//:tempfile",
45+
"@crates//:tokio",
46+
"@crates//:unicode-segmentation",
47+
],
48+
)
49+
50+
rust_binary(
51+
name = "gitui",
52+
srcs = ["src/main.rs"],
53+
edition = "2024",
54+
rustc_flags = ["-Clink-arg=-fuse-ld=bfd"],
55+
deps = [
56+
":gitui_lib",
57+
"@crates//:anyhow",
58+
"@crates//:clap",
59+
"@crates//:tokio",
60+
],
61+
)
62+
63+
TEST_SRCS = glob(["test/*.rs"])
64+
65+
[
66+
rust_test(
67+
name = test_file.replace("test/", "").replace(".rs", ""),
68+
size = "small",
69+
srcs = [test_file],
70+
edition = "2024",
71+
rustc_flags = ["-Clink-arg=-fuse-ld=bfd"],
72+
data = glob(["test/snapshots/**"]),
73+
deps = [
74+
":gitui_lib",
75+
"@crates//:anyhow",
76+
"@crates//:crossterm",
77+
"@crates//:git2",
78+
"@crates//:insta",
79+
"@crates//:petgraph",
80+
"@crates//:proptest",
81+
"@crates//:ratatui",
82+
"@crates//:regex",
83+
"@crates//:tempfile",
84+
"@crates//:tokio",
85+
],
86+
)
87+
for test_file in TEST_SRCS
88+
]
89+
90+
rust_clippy(
91+
name = "clippy",
92+
testonly = True,
93+
deps = [
94+
":gitui",
95+
":gitui_lib",
96+
] + [
97+
":" + src.replace("test/", "").replace(".rs", "")
98+
for src in TEST_SRCS
99+
],
100+
)

tools/gitui/README.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Git Stack Manager (gitui)
2+
3+
A simple Git TUI for managing branch stacks and complex branch trees.
4+
5+
## Overview
6+
7+
This tool is designed to simplify the management of "stacked" branches, where
8+
multiple feature branches are built on top of each other. It provides a visual
9+
representation of the branch hierarchy and allows for easy restructuring of
10+
entire subtrees.
11+
12+
## Key Features
13+
14+
- **Visual Branch Tree:** Automatically detects and displays the relationship
15+
between local and remote branches.
16+
- **Interactive Move:** "Grab" a branch and move it to a new parent. The tool
17+
handles the rebase of the entire subtree.
18+
- **Predictive Conflict Detection:** Highlights potential merge conflicts
19+
*while* you are moving a branch, before any action is taken.
20+
- **Heuristic Repair (`u`):** Automatically detects when a branch has drifted
21+
from its true parent (e.g. after a remote rebase) and allows you to
22+
"converge" it back with a single keypress.
23+
- **Split Branch (`x`):** Interactively decompose a single commit into
24+
multiple sequential branches by selecting specific hunks.
25+
- **Remote Visibility:** Toggle between local-only and tracking views for
26+
`origin` and `upstream` remotes.
27+
- **Submit Workflow:** Plan and execute branch submissions to `upstream` with
28+
automatic sync to `origin`.
29+
- **Localize Remotes:** Easily create local tracking branches from remote
30+
branches by simply moving them in the tree.
31+
- **Branch Management:** Directly push (`p`), delete (`d`), reset (`r`),
32+
rename (`R`), or amend (`m`/`M`) branches from the TUI.
33+
34+
## Shortcuts
35+
36+
### Navigation
37+
38+
- `j` / `Down`: Move selection down.
39+
- `k` / `Up`: Move selection up.
40+
- `a`: Toggle showing remote branches from `origin` and `upstream`.
41+
42+
### Manipulation
43+
44+
- `Space`: Grab or drop a branch. While grabbed, use `j`/`k` to select a new
45+
parent, or `h` to move to root.
46+
- `p`: Toggle pending push (for local branches with ahead commits).
47+
- `s`: Toggle pending submit (push to `upstream`, delete from `origin`, merge
48+
to `master`).
49+
- `x`: Enter Split Branch mode (only available if 1 commit ahead of parent).
50+
- `u`: Converge diverged branch (move to heuristic parent).
51+
- `d`: Toggle pending delete.
52+
- `r`: Toggle pending reset to upstream (or rebase onto upstream if
53+
ahead/behind).
54+
- `m`: Toggle pending amend (amends current staged changes into the selected
55+
branch).
56+
- `M`: Toggle pending amend with message update.
57+
- `R`: Rename the selected branch.
58+
- `f`: Toggle pending localize (for remote branches) or fetch (for root).
59+
60+
### Execution
61+
62+
- `v`: Enter Preview mode to see planned operations and predicted conflicts.
63+
- `c`: Execute all pending operations.
64+
- `Esc`: Cancel current grab or quit the current mode.
65+
- `q`: Quit.
66+
67+
## CLI Usage
68+
69+
```bash
70+
# Start the TUI in the current directory
71+
gitui
72+
73+
# Start in a specific directory
74+
gitui --path /path/to/repo
75+
76+
# Print the current tree and exit
77+
gitui --tree
78+
79+
# Print the tree including remote branches
80+
gitui --tree --all
81+
82+
# Show the submission plan for a branch and exit
83+
gitui --submit branch-name
84+
85+
# Show the plan to fix a diverged branch (converge)
86+
gitui --converge branch-name
87+
88+
# Show the plan to sync a branch with its upstream
89+
gitui --sync branch-name
90+
```
91+
92+
## Development
93+
94+
This tool is built with:
95+
96+
- **Language:** Rust
97+
- **UI:** [Ratatui](https://ratatui.rs/)
98+
- **Git Engine:** [git2-rs](https://github.com/rust-lang/git2-rs)
99+
- **Build System:** Bazel
100+
101+
### Building
102+
103+
```bash
104+
bazel build //hs-github-tools/tools/gitui:gitui
105+
```
106+
107+
### Testing
108+
109+
```bash
110+
bazel test //hs-github-tools/tools/gitui/...
111+
```

tools/gitui/src/diff_utils.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
use git2::{Diff, DiffFormat, DiffLineType};
2+
3+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
4+
pub enum LineType {
5+
#[default]
6+
Context,
7+
Addition,
8+
Deletion,
9+
Header,
10+
}
11+
12+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
13+
pub struct DiffLine {
14+
pub content: String,
15+
pub line_type: LineType,
16+
pub old_lineno: Option<u32>,
17+
pub new_lineno: Option<u32>,
18+
}
19+
20+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
21+
pub struct Hunk {
22+
pub header: String,
23+
pub lines: Vec<DiffLine>,
24+
pub old_start: u32,
25+
pub old_lines: u32,
26+
pub new_start: u32,
27+
pub new_lines: u32,
28+
}
29+
30+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
31+
pub struct FileDiff {
32+
pub path: String,
33+
pub hunks: Vec<Hunk>,
34+
}
35+
36+
pub fn parse_diff(diff: &Diff) -> anyhow::Result<Vec<FileDiff>> {
37+
let mut file_diffs = Vec::new();
38+
let mut current_file: Option<FileDiff> = None;
39+
let mut current_hunk: Option<Hunk> = None;
40+
41+
diff.print(DiffFormat::Patch, |delta, hunk, line| {
42+
let path_str = delta
43+
.new_file()
44+
.path()
45+
.and_then(|p| p.to_str())
46+
.unwrap_or("");
47+
48+
let is_different_file = match &current_file {
49+
Some(f) => f.path != path_str,
50+
None => true,
51+
};
52+
53+
if is_different_file {
54+
if let Some(h) = current_hunk.take()
55+
&& let Some(ref mut f) = current_file
56+
{
57+
f.hunks.push(h);
58+
}
59+
if let Some(f) = current_file.take() {
60+
file_diffs.push(f);
61+
}
62+
current_file = Some(FileDiff {
63+
path: path_str.to_string(),
64+
hunks: Vec::new(),
65+
});
66+
}
67+
68+
if let Some(h) = hunk {
69+
let is_different_hunk = match &current_hunk {
70+
Some(curr) => {
71+
curr.old_start != h.old_start()
72+
|| curr.old_lines != h.old_lines()
73+
|| curr.new_start != h.new_start()
74+
|| curr.new_lines != h.new_lines()
75+
}
76+
None => true,
77+
};
78+
79+
if is_different_hunk {
80+
if let Some(h_val) = current_hunk.take()
81+
&& let Some(ref mut f) = current_file
82+
{
83+
f.hunks.push(h_val);
84+
}
85+
86+
let header = std::str::from_utf8(h.header())
87+
.unwrap_or("")
88+
.trim()
89+
.to_string();
90+
91+
current_hunk = Some(Hunk {
92+
header,
93+
lines: Vec::new(),
94+
old_start: h.old_start(),
95+
old_lines: h.old_lines(),
96+
new_start: h.new_start(),
97+
new_lines: h.new_lines(),
98+
});
99+
}
100+
}
101+
102+
let line_type = match line.origin_value() {
103+
DiffLineType::Context => LineType::Context,
104+
DiffLineType::Addition => LineType::Addition,
105+
DiffLineType::Deletion => LineType::Deletion,
106+
_ => LineType::Header,
107+
};
108+
109+
if line_type != LineType::Header
110+
&& let Some(ref mut h_val) = current_hunk
111+
{
112+
h_val.lines.push(DiffLine {
113+
content: std::str::from_utf8(line.content())
114+
.unwrap_or("")
115+
.to_string(),
116+
line_type,
117+
old_lineno: line.old_lineno(),
118+
new_lineno: line.new_lineno(),
119+
});
120+
}
121+
122+
true
123+
})?;
124+
125+
if let Some(h) = current_hunk
126+
&& let Some(ref mut f) = current_file
127+
{
128+
f.hunks.push(h);
129+
}
130+
if let Some(f) = current_file {
131+
file_diffs.push(f);
132+
}
133+
134+
Ok(file_diffs)
135+
}

0 commit comments

Comments
 (0)