pub mod app;
pub mod input;
pub mod ui;
use std::io::{self, Write};
use std::process::Command;
use color_eyre::Result;
use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use crate::core::config::Config;
use crate::core::stack::Stack;
use crate::forge;
use crate::git::ops::Repo;
use app::{App, SuspendReason};
pub type Tui = Terminal<CrosstermBackend<io::Stdout>>;
pub fn run() -> Result<()> {
let repo = Repo::open()?;
if repo.is_rebase_in_progress() {
eprintln!(" \x1b[31m⚠ A rebase is already in progress.\x1b[0m");
eprintln!(" Run \x1b[1mgit rebase --continue\x1b[0m or \x1b[1mgit rebase --abort\x1b[0m first.");
return Ok(());
}
if repo.has_uncommitted_changes() {
eprintln!(" \x1b[31m⚠ You have uncommitted changes.\x1b[0m");
eprintln!(" Commit or stash them before running pgit.");
return Ok(());
}
let base = repo.detect_base()?;
let mut commits = repo.list_stack_commits()?;
let config = Config::load(&repo.workdir)
.unwrap_or_else(|| Config {
forge: crate::core::config::ForgeConfig {
forge_type: "github".to_string(),
submit_cmd: None,
},
repo: crate::core::config::RepoConfig { base: None },
});
let f = forge::create_forge(&config);
crate::core::config::check_dependencies(&config);
f.mark_submitted(&repo, &mut commits);
let stack = Stack::new(base, commits);
let mut app = App::new(stack, f);
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original_hook(panic);
}));
loop {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
app.run(&mut terminal)?;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
if app.should_quit {
break;
}
match app.wants_suspend.take() {
Some(SuspendReason::InsertAtHead) => handle_insert_at_head(&mut app)?,
Some(SuspendReason::InsertAfterCursor { hash }) => {
handle_insert_after(&mut app, &hash)?;
}
Some(SuspendReason::EditCommit { hash }) => handle_edit_commit(&mut app, &hash)?,
Some(SuspendReason::SquashCommits { hashes, default_body, trailers }) => {
handle_squash_commits(&mut app, &hashes, &default_body, &trailers)?;
}
Some(SuspendReason::SubmitCommit { hash, subject, cursor_index }) => {
handle_submit_commit(&mut app, &hash, &subject, cursor_index)?;
}
Some(SuspendReason::UpdatePR { hash, subject, cursor_index }) => {
handle_update_pr(&mut app, &hash, &subject, cursor_index)?;
}
Some(SuspendReason::SyncPRs) => {
handle_sync_prs(&mut app)?;
}
Some(SuspendReason::Rebase) => {
handle_rebase(&mut app)?;
}
Some(SuspendReason::RebaseConflict) => handle_rebase_conflict(&mut app)?,
None => break,
}
}
Ok(())
}
fn print_box(color: &str, title: &str, lines: &[&str]) {
let width: usize = 62;
let title_part = format!("─ {} ", title);
let title_visible = strip_ansi_len(&title_part);
let top_fill = "─".repeat(width.saturating_sub(2 + title_visible));
println!("\n\x1b[1;{color}m┌{title_part}{top_fill}┐\x1b[0m");
let inner = width.saturating_sub(5); for line in lines {
let visible_len = strip_ansi_len(line);
let pad = inner.saturating_sub(visible_len);
println!("\x1b[1;{color}m│\x1b[0m {line}{} \x1b[1;{color}m│\x1b[0m", " ".repeat(pad));
}
let bottom = "─".repeat(width.saturating_sub(2));
println!("\x1b[1;{color}m└{bottom}┘\x1b[0m\n");
}
fn strip_ansi_len(s: &str) -> usize {
let mut len = 0;
let mut in_escape = false;
for c in s.chars() {
if c == '\x1b' {
in_escape = true;
} else if in_escape {
if c == 'm' { in_escape = false; }
} else {
len += 1;
}
}
len
}
fn wait_for_enter() -> Result<()> {
io::stdout().flush()?;
let mut buf = String::new();
io::stdin().read_line(&mut buf)?;
Ok(())
}
fn clear_screen() {
print!("\x1b[2J\x1b[H");
let _ = io::stdout().flush();
}
fn get_editor() -> String {
std::env::var("EDITOR")
.or_else(|_| std::env::var("VISUAL"))
.unwrap_or_else(|_| "nano".to_string())
}
fn read_single_key() -> Result<char> {
enable_raw_mode()?;
let ch = loop {
if let Event::Key(key) = event::read()? {
if let KeyCode::Char(c) = key.code {
break c;
}
}
};
disable_raw_mode()?;
Ok(ch)
}
fn handle_insert_at_head(app: &mut App) -> Result<()> {
print_box("36", "pilegit: insert commit at top", &[
"Make your changes and commit:",
"",
" \x1b[1;33mgit add <files> && git commit -m \"...\"\x1b[0m",
"",
"Press \x1b[1;32mEnter\x1b[0m when done to return.",
]);
wait_for_enter()?;
match app.reload_stack() {
Ok(()) => {
app.record_reload("insert commit at top");
app.notify("Stack refreshed.");
}
Err(e) => app.notify(format!("Reload failed: {}", e)),
}
Ok(())
}
fn handle_insert_after(app: &mut App, hash: &str) -> Result<()> {
let repo = Repo::open()?;
match repo.rebase_break_after(hash) {
Ok(false) => {
print_box("36", &format!("pilegit: insert after {}", hash), &[
"Rebase paused at the right position.",
"Make your changes and commit:",
"",
" \x1b[1;33mgit add <files> && git commit -m \"...\"\x1b[0m",
"",
"Press \x1b[1;32mEnter\x1b[0m when done. pilegit will rebase the rest.",
]);
wait_for_enter()?;
match repo.rebase_continue() {
Ok(true) => {
app.reload_stack()?;
app.record_reload("insert commit after cursor");
app.notify("Inserted commit and rebased.");
}
Ok(false) => {
app.notify("Conflict during rebase after insert.");
app.wants_suspend = Some(SuspendReason::RebaseConflict);
}
Err(e) => app.notify(format!("Rebase continue failed: {}", e)),
}
}
Ok(true) => {
app.notify("Break point not reached. Try inserting at top instead (t).");
}
Err(e) => app.notify(format!("Insert failed: {}", e)),
}
Ok(())
}
fn handle_edit_commit(app: &mut App, hash: &str) -> Result<()> {
let repo = Repo::open()?;
match repo.rebase_edit_commit(hash) {
Ok(false) => {
print_box("33", &format!("pilegit: editing commit {}", hash), &[
"Rebase is paused at this commit.",
"The working tree has the state of this commit.",
"Make your changes to the code now.",
"",
"When you press \x1b[1;32mEnter\x1b[0m, pilegit will:",
" 1. Stage all changes (git add -A)",
" 2. Amend this commit (git commit --amend --no-edit)",
" 3. Rebase the remaining commits on top",
"",
"Press \x1b[1;32mEnter\x1b[0m when ready.",
]);
wait_for_enter()?;
println!(" Staging and amending...");
let _ = Command::new("git")
.current_dir(&repo.workdir)
.args(["add", "-A"])
.output();
let amend = Command::new("git")
.current_dir(&repo.workdir)
.args(["commit", "--amend", "--no-edit"])
.output()?;
if !amend.status.success() {
let stderr = String::from_utf8_lossy(&amend.stderr);
if !stderr.contains("nothing to commit") {
app.notify(format!("Amend warning: {}", stderr.trim()));
}
}
match repo.rebase_continue() {
Ok(true) => {
app.reload_stack()?;
app.record_reload(&format!("edit commit {}", hash));
app.notify("Commit edited and rebased successfully.");
}
Ok(false) => {
app.notify("Conflict during rebase after edit.");
app.wants_suspend = Some(SuspendReason::RebaseConflict);
}
Err(e) => app.notify(format!("Rebase continue failed: {}", e)),
}
}
Ok(true) => {
app.notify("Commit not found in stack range.");
}
Err(e) => app.notify(format!("Edit failed: {}", e)),
}
Ok(())
}
fn handle_squash_commits(
app: &mut App,
hashes: &[String],
default_body: &str,
trailers: &[String],
) -> Result<()> {
let mut initial_content = format!(
"# Pick or combine the commit messages below.\n\
# The first line becomes the commit subject.\n\
# Lines starting with # are ignored.\n\n\
{}\n",
default_body
);
if !trailers.is_empty() {
initial_content.push('\n');
for t in trailers {
initial_content.push_str(t);
initial_content.push('\n');
}
}
let tmp_path = std::env::temp_dir().join(format!("pgit-squash-msg-{}.txt", std::process::id()));
std::fs::write(&tmp_path, &initial_content)?;
let editor = get_editor();
println!();
print_box("36", "pilegit: edit squash message", &[
&format!("Squashing {} commits. Edit the combined commit message.", hashes.len()),
"",
&format!(" Editor: \x1b[1;33m{}\x1b[0m", editor),
"",
" First line = commit subject",
" Remaining lines = commit body",
"",
" Save and close the editor when done.",
]);
let status = Command::new(&editor)
.arg(&tmp_path)
.status();
match status {
Ok(s) if s.success() => {
let edited = std::fs::read_to_string(&tmp_path)?;
let _ = std::fs::remove_file(&tmp_path);
let message: String = edited.lines()
.filter(|l| !l.starts_with('#'))
.collect::<Vec<_>>()
.join("\n");
let mut message = message.trim().to_string();
if message.is_empty() {
app.notify("Empty message — squash cancelled.");
return Ok(());
}
for trailer in trailers {
if !message.contains(trailer.as_str()) {
message.push_str("\n\n");
message.push_str(trailer);
}
}
clear_screen();
println!();
println!(" \x1b[1;36m▸ Squashing {} commits...\x1b[0m", hashes.len());
println!();
let repo = Repo::open()?;
match repo.squash_commits_with_message(hashes, &message) {
Ok(true) => {
app.reload_stack()?;
app.record_reload(&format!("squash {} commits", hashes.len()));
app.notify(format!("Squashed {} commits.", hashes.len()));
}
Ok(false) => {
app.notify("Conflict during squash.");
app.wants_suspend = Some(SuspendReason::RebaseConflict);
}
Err(e) => app.notify(format!("Squash failed: {}", e)),
}
}
Ok(_) => {
let _ = std::fs::remove_file(&tmp_path);
app.notify("Editor exited with error — squash cancelled.");
}
Err(e) => {
let _ = std::fs::remove_file(&tmp_path);
app.notify(format!("Could not open {}: {}", editor, e));
}
}
Ok(())
}
fn handle_submit_commit(
app: &mut App,
hash: &str,
subject: &str,
cursor_index: usize,
) -> Result<()> {
clear_screen();
let short = &hash[..7.min(hash.len())];
let repo = Repo::open()?;
let (open_prs, gh_avail) = app.forge.list_open(&repo);
let base = repo.determine_base_for_commit(
&app.stack.patches, cursor_index, &open_prs, gh_avail,
);
if !app.forge.needs_description_editor() {
clear_screen();
println!();
println!(" \x1b[1;36m▸ Submitting: {}\x1b[0m", subject);
println!();
match app.forge.submit(&repo, hash, subject, &base, "") {
Ok(out) => {
println!();
println!(" \x1b[32m✓ {}\x1b[0m", out);
let _ = app.reload_stack();
app.notify(out);
}
Err(e) => {
println!();
println!(" \x1b[31m✗ Submit failed: {}\x1b[0m", e);
app.notify(format!("Submit failed: {}", e));
}
}
println!();
println!(" Press \x1b[1;32mEnter\x1b[0m to return.");
wait_for_enter()?;
return Ok(());
}
let template = format!("## Description\n\n{}\n\n## Test Plan\n\n\n", subject);
let tmp_path = std::env::temp_dir().join(format!("pgit-pr-msg-{}.txt", std::process::id()));
std::fs::write(&tmp_path, &template)?;
let editor = get_editor();
print_box("36", &format!("pilegit: submit {}", short), &[
"Write your PR description.",
"",
&format!(" Editor: \x1b[1;33m{}\x1b[0m", editor),
&format!(" Commit: \x1b[1;33m{} {}\x1b[0m", short, subject),
&format!(" Base: \x1b[1;33m{}\x1b[0m", base),
"",
" Save and close the editor when done.",
" Leave empty to cancel.",
]);
let status = Command::new(&editor)
.arg(&tmp_path)
.status();
match status {
Ok(s) if s.success() => {
let body = std::fs::read_to_string(&tmp_path)?;
let _ = std::fs::remove_file(&tmp_path);
let body = body.trim().to_string();
if body.is_empty() {
app.notify("Empty description — submit cancelled.");
return Ok(());
}
clear_screen();
println!();
println!(" \x1b[1;36m▸ Submitting: {}\x1b[0m", subject);
println!();
println!(" \x1b[33mPushing branch and creating PR...\x1b[0m");
match app.forge.submit(&repo, hash, subject, &base, &body) {
Ok(out) => {
println!();
println!(" \x1b[32m✓ {}\x1b[0m", out);
let _ = app.reload_stack();
app.notify(out);
}
Err(e) => {
println!();
println!(" \x1b[31m✗ Submit failed: {}\x1b[0m", e);
app.notify(format!("Submit failed: {}", e));
}
}
println!();
println!(" Press \x1b[1;32mEnter\x1b[0m to return.");
wait_for_enter()?;
}
Ok(_) => {
let _ = std::fs::remove_file(&tmp_path);
app.notify("Editor exited with error — submit cancelled.");
}
Err(e) => {
let _ = std::fs::remove_file(&tmp_path);
app.notify(format!("Could not open {}: {}", editor, e));
}
}
Ok(())
}
fn handle_update_pr(
app: &mut App,
hash: &str,
subject: &str,
cursor_index: usize,
) -> Result<()> {
clear_screen();
let short = &hash[..7.min(hash.len())];
println!();
println!(" \x1b[1;36m▸ Updating PR: {}\x1b[0m", subject);
println!();
let repo = Repo::open()?;
println!(" \x1b[33mDetermining base...\x1b[0m");
let (open_prs, gh_avail) = app.forge.list_open(&repo);
let pr_base = repo.determine_base_for_commit(&app.stack.patches, cursor_index, &open_prs, gh_avail);
clear_screen();
println!();
println!(" \x1b[1;36m▸ Updating PR: {}\x1b[0m", subject);
println!();
println!(" \x1b[33mForce-pushing {} → {}\x1b[0m", short, pr_base);
match app.forge.update(&repo, hash, subject, &pr_base) {
Ok(msg) => {
println!();
println!(" \x1b[32m✓ {}\x1b[0m", msg);
let _ = app.reload_stack();
app.notify(msg);
}
Err(e) => {
println!();
println!(" \x1b[31m✗ Update failed: {}\x1b[0m", e);
app.notify(format!("Update failed: {}", e));
}
}
println!();
println!(" Press \x1b[1;32mEnter\x1b[0m to return.");
wait_for_enter()?;
Ok(())
}
fn handle_rebase(app: &mut App) -> Result<()> {
clear_screen();
println!();
println!(" \x1b[1;36m▸ Rebasing stack...\x1b[0m");
println!();
let repo = Repo::open()?;
match repo.rebase_onto_base(&|msg| {
println!(" \x1b[33m{}\x1b[0m", msg);
}) {
Ok(true) => {
app.reload_stack()?;
app.record_reload("rebase onto base");
if let Ok(r) = Repo::open() {
let _ = app.forge.fix_dependencies(&r);
let _ = app.reload_stack();
}
clear_screen();
println!();
println!(" \x1b[32m✓ Rebase completed. Stack: {} commits.\x1b[0m", app.stack.len());
let submitted_count = app.stack.patches.iter()
.filter(|p| p.status == crate::core::stack::PatchStatus::Submitted)
.count();
if submitted_count > 0 {
println!();
println!(" \x1b[1;36m▸ Syncing {} submitted PRs...\x1b[0m", submitted_count);
println!();
if let Ok(r) = Repo::open() {
let patches = app.stack.patches.clone();
match app.forge.sync(&r, &patches, &|msg| {
println!(" \x1b[33m{}\x1b[0m", msg);
}) {
Ok(updates) => {
clear_screen();
println!();
println!(" \x1b[32m✓ Rebase completed. Stack: {} commits.\x1b[0m", app.stack.len());
println!();
println!(" \x1b[32m✓ Synced {} PRs:\x1b[0m", updates.len());
for u in &updates {
println!(" {}", u);
}
}
Err(e) => println!(" \x1b[31mSync warning: {}\x1b[0m", e),
}
}
let _ = app.reload_stack();
}
prompt_cleanup_stale_branches(app)?;
app.notify("Rebase completed.");
}
Ok(false) => {
clear_screen();
println!();
println!(" \x1b[31m⚠ Conflicts detected.\x1b[0m");
app.notify("Conflict during rebase.");
app.wants_suspend = Some(SuspendReason::RebaseConflict);
return Ok(());
}
Err(e) => {
clear_screen();
println!();
println!(" \x1b[31m✗ Rebase failed: {}\x1b[0m", e);
app.notify(format!("Rebase failed: {}", e));
}
}
println!();
println!(" Press \x1b[1;32mEnter\x1b[0m to return.");
wait_for_enter()?;
Ok(())
}
fn handle_sync_prs(app: &mut App) -> Result<()> {
clear_screen();
println!();
println!(" \x1b[1;36m▸ Syncing PRs...\x1b[0m");
println!();
let repo = Repo::open()?;
let _ = app.forge.fix_dependencies(&repo);
let _ = app.reload_stack();
let repo = Repo::open()?;
let base = repo.detect_base()?;
let base_branch = base.strip_prefix("origin/").unwrap_or(&base).to_string();
let patches = app.stack.patches.clone();
match app.forge.sync(&repo, &patches, &|msg| {
println!(" \x1b[33m{}\x1b[0m", msg);
}) {
Ok(updates) => {
clear_screen();
println!();
if updates.is_empty() {
println!(" \x1b[32m✓ No open PRs to sync.\x1b[0m");
} else {
println!(" \x1b[32m✓ Synced {} PRs:\x1b[0m", updates.len());
println!();
for u in &updates {
println!(" {}", u);
}
let ready: Vec<&String> = updates.iter()
.filter(|u| u.starts_with("✓") && u.ends_with(&format!("→ {}", base_branch)))
.collect();
if !ready.is_empty() {
println!();
println!(" \x1b[1;32m▸ Ready to merge into {}:\x1b[0m", base_branch);
for r in &ready {
let branch = r.trim_start_matches("✓ ")
.split(" → ").next().unwrap_or(r);
println!(" \x1b[1;33m{}\x1b[0m", branch);
}
}
let failed: Vec<&String> = updates.iter()
.filter(|u| u.starts_with("⚠"))
.collect();
if !failed.is_empty() {
println!();
println!(" \x1b[1;31m⚠ Failed:\x1b[0m");
for f in &failed {
println!(" {}", f);
}
}
}
let _ = app.reload_stack();
app.notify(format!("Synced {} PRs.", updates.len()));
}
Err(e) => {
clear_screen();
println!();
println!(" \x1b[31m✗ Sync failed: {}\x1b[0m", e);
app.notify(format!("Sync failed: {}", e));
}
}
prompt_cleanup_stale_branches(app)?;
println!();
println!(" Press \x1b[1;32mEnter\x1b[0m to return.");
wait_for_enter()?;
Ok(())
}
fn prompt_cleanup_stale_branches(app: &App) -> Result<()> {
let repo = Repo::open()?;
let (open_prs, gh_avail) = app.forge.list_open(&repo);
let mut stale = repo.find_stale_branches_with(&open_prs, gh_avail);
let all_branches = repo.list_pgit_branches();
let landed = app.forge.find_landed_branches(&repo, &all_branches);
for b in landed {
if !stale.contains(&b) {
stale.push(b);
}
}
if stale.is_empty() {
return Ok(());
}
println!();
println!(" \x1b[33m▸ Found {} stale branches (PR merged or closed):\x1b[0m", stale.len());
for b in &stale {
println!(" {}", b);
}
println!();
print!(" Delete these branches (local + remote)? \x1b[1;32my\x1b[0m/\x1b[1;31mn\x1b[0m ");
io::stdout().flush()?;
let choice = read_single_key()?;
println!();
if choice == 'y' {
println!(" Deleting...");
for b in &stale {
println!(" \x1b[33m{}\x1b[0m", b);
}
repo.delete_branches(&stale);
println!(" \x1b[32m✓ Deleted {} stale branches.\x1b[0m", stale.len());
} else {
println!(" Skipped.");
}
println!();
Ok(())
}
fn handle_rebase_conflict(app: &mut App) -> Result<()> {
loop {
let repo = Repo::open()?;
let conflicts = repo.conflicted_files().unwrap_or_default();
if conflicts.is_empty() {
println!(" No remaining conflicts. Continuing rebase...");
match app.continue_rebase() {
Ok(true) => return Ok(()),
Ok(false) => continue, Err(e) => {
app.notify(format!("Continue failed: {}", e));
return Ok(());
}
}
}
let mut lines: Vec<String> = vec![];
lines.push("Conflicting files:".into());
for f in &conflicts {
lines.push(format!(" \x1b[1;33m{}\x1b[0m", f));
}
lines.extend_from_slice(&[
String::new(),
"Resolve conflicts, then stage: \x1b[1;33mgit add <files>\x1b[0m".into(),
String::new(),
"Press \x1b[1;32mc\x1b[0m to continue or \x1b[1;31ma\x1b[0m to abort".into(),
]);
let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
print_box("31", "pilegit: rebase conflict", &line_refs);
let choice = read_single_key()?;
match choice {
'c' => {
println!(" Continuing rebase...");
match app.continue_rebase() {
Ok(true) => return Ok(()),
Ok(false) => continue, Err(e) => {
app.notify(format!("Continue failed: {}", e));
return Ok(());
}
}
}
'a' => {
println!(" Aborting rebase...");
let _ = app.abort_rebase();
return Ok(());
}
_ => {
println!(" Press 'c' to continue or 'a' to abort.");
}
}
}
}