Skip to content
Merged
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
122 changes: 117 additions & 5 deletions crates/bitbygit-git/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -481,10 +481,11 @@ impl Git {
}

fn in_progress_operation(&self) -> Result<Option<&'static str>, GitError> {
if let Some(operation) = self.repository_operation()? {
return Ok(Some(operation.label()));
}
for (operation, marker) in [
("merge", "MERGE_HEAD"),
("rebase", "rebase-merge"),
("rebase", "rebase-apply"),
("am", "rebase-apply/applying"),
("cherry-pick", "CHERRY_PICK_HEAD"),
("revert", "REVERT_HEAD"),
] {
Expand All @@ -495,6 +496,19 @@ impl Git {
Ok(None)
}

fn repository_operation(&self) -> Result<Option<RepositoryOperation>, GitError> {
if self.git_path("MERGE_HEAD")?.exists() {
return Ok(Some(RepositoryOperation::Merge));
}
let rebase_apply = self.git_path("rebase-apply")?;
if self.git_path("rebase-merge")?.exists()
|| (rebase_apply.exists() && !rebase_apply.join("applying").exists())
{
return Ok(Some(RepositoryOperation::Rebase));
}
Ok(None)
}

fn git_path(&self, path: &str) -> Result<PathBuf, GitError> {
let output = self.run_args(vec![
"rev-parse".to_owned(),
Expand Down Expand Up @@ -617,7 +631,9 @@ impl Git {

pub fn status(&self) -> Result<WorktreeStatus, GitError> {
let output = self.run_raw(["status", "--porcelain=v2", "--branch", "-z"])?;
parse_status_bytes(&output.stdout)
let mut status = parse_status_bytes(&output.stdout)?;
status.operation = self.repository_operation()?;
Ok(status)
}

pub fn stage_path(&self, path: &Path) -> Result<GitOutput, GitError> {
Expand Down Expand Up @@ -1233,6 +1249,21 @@ pub enum Head {
Unborn,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepositoryOperation {
Merge,
Rebase,
}

impl RepositoryOperation {
fn label(self) -> &'static str {
match self {
Self::Merge => "merge",
Self::Rebase => "rebase",
}
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Remote {
pub name: String,
Expand All @@ -1244,6 +1275,7 @@ pub struct Remote {
pub struct WorktreeStatus {
pub branch: BranchState,
pub entries: Vec<StatusEntry>,
pub operation: Option<RepositoryOperation>,
}

impl WorktreeStatus {
Expand Down Expand Up @@ -1438,7 +1470,11 @@ fn parse_status_bytes(input: &[u8]) -> Result<WorktreeStatus, GitError> {
}
}

Ok(WorktreeStatus { branch, entries })
Ok(WorktreeStatus {
branch,
entries,
operation: None,
})
}

fn parse_ahead_behind(value: &str) -> Result<(u32, u32), GitError> {
Expand Down Expand Up @@ -1735,6 +1771,7 @@ mod tests {
assert_eq!(status.branch.upstream, Some("origin/main".to_owned()));
assert_eq!(status.branch.ahead, 0);
assert_eq!(status.branch.behind, 0);
assert_eq!(status.operation, None);
Ok(())
}

Expand Down Expand Up @@ -2042,6 +2079,8 @@ mod tests {
repo.run_allow_failure(["rebase", "--exec", "false", "main"])?;
let git = Git::new(repo.path());

assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase));

let result = git.create_branch("new-topic", None, &git.head_target()?);

let Err(error) = result else {
Expand Down Expand Up @@ -2074,11 +2113,84 @@ mod tests {

let status = Git::new(repo.path()).status()?;

assert_eq!(status.operation, Some(RepositoryOperation::Merge));
assert_eq!(status.conflicted_files().len(), 1);
assert_eq!(
status.conflicted_files()[0].path,
PathBuf::from("conflict.txt")
);

repo.run(["add", "conflict.txt"])?;
let status = Git::new(repo.path()).status()?;

assert_eq!(status.operation, Some(RepositoryOperation::Merge));
assert!(status.conflicted_files().is_empty());
Ok(())
}

#[test]
fn detects_both_rebase_backends_and_no_operation_in_clean_repo() -> Result<(), Box<dyn Error>> {
let repo = TempRepo::new()?;
repo.run(["init", "-b", "main"])?;
let git = Git::new(repo.path());

assert_eq!(git.status()?.operation, None);

for marker in ["rebase-merge", "rebase-apply"] {
let path = git.git_path(marker)?;
fs::create_dir(&path)?;
assert_eq!(git.status()?.operation, Some(RepositoryOperation::Rebase));
fs::remove_dir(path)?;
}
Ok(())
}

#[test]
fn paused_am_is_not_reported_as_rebase_and_remains_blocking() -> Result<(), Box<dyn Error>> {
let repo = TempRepo::new()?;
repo.run(["init", "-b", "main"])?;
repo.run(["config", "user.email", "bitbygit@example.invalid"])?;
repo.run(["config", "user.name", "bitbygit test"])?;
repo.write("README.md", "initial\n")?;
repo.run(["add", "README.md"])?;
repo.run(["commit", "-m", "initial"])?;
repo.run(["commit", "--allow-empty", "-m", "empty"])?;
let patch = repo.git_stdout(["format-patch", "-1", "--stdout"])?;
repo.run(["reset", "--hard", "HEAD~"])?;
repo.write("empty.patch", &patch)?;
repo.run_allow_failure(["am", "empty.patch"])?;
let git = Git::new(repo.path());

assert!(git.git_path("rebase-apply/applying")?.exists());
assert_eq!(git.status()?.operation, None);
let Err(error) = git.ensure_clean_worktree("checkout") else {
return Err("expected paused am guardrail".into());
};
assert!(error.to_string().contains("am operation is in progress"));
Ok(())
}

#[test]
fn cherry_pick_and_revert_markers_remain_blocking() -> Result<(), Box<dyn Error>> {
let repo = TempRepo::new()?;
repo.run(["init", "-b", "main"])?;
let git = Git::new(repo.path());

for (marker, operation) in [
("CHERRY_PICK_HEAD", "cherry-pick"),
("REVERT_HEAD", "revert"),
] {
let path = git.git_path(marker)?;
fs::write(&path, "marker\n")?;

assert_eq!(git.status()?.operation, None);
let Err(error) = git.ensure_clean_worktree("checkout") else {
return Err(format!("expected {operation} marker to block checkout").into());
};
assert!(error.to_string().contains(operation));

fs::remove_file(path)?;
}
Ok(())
}

Expand Down
38 changes: 32 additions & 6 deletions crates/bitbygit-tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use bitbygit_core::{
use bitbygit_gh::{CreatePullRequest, GhError, GitHub};
use bitbygit_git::{
BranchInfo, BranchKind, BranchState, BranchTarget, ChangeKind, Git, GitError, GitOutput, Head,
HeadTarget, StatusEntry, StatusEntryType,
HeadTarget, RepositoryOperation, StatusEntry, StatusEntryType,
};
use bitbygit_store::{AuditEntry, LocalStore, RepoId, StorePaths};

Expand Down Expand Up @@ -131,6 +131,7 @@ pub struct App {
selected_repo: usize,
files: Vec<FileRow>,
branch: Option<BranchState>,
repository_operation: Option<RepositoryOperation>,
selected_file: usize,
file_scroll: usize,
details: String,
Expand All @@ -152,6 +153,7 @@ impl App {
selected_repo: 0,
files: Vec::new(),
branch: None,
repository_operation: None,
selected_file: 0,
file_scroll: 0,
details: "No repository status loaded yet.".to_owned(),
Expand Down Expand Up @@ -335,6 +337,7 @@ impl App {
.flat_map(FileRow::from_entry)
.collect();
self.branch = Some(status.branch);
self.repository_operation = status.operation;
self.files.sort_by(|left, right| {
left.section
.cmp(&right.section)
Expand All @@ -349,6 +352,7 @@ impl App {
Err(error) => {
self.files.clear();
self.branch = None;
self.repository_operation = None;
self.selected_file = 0;
self.details = format!("Unable to read repository status: {error}");
}
Expand Down Expand Up @@ -497,7 +501,7 @@ impl App {
}

fn clamp_file_scroll_for(&mut self, area: Rect) {
let visible_len = status_file_visible_len(area);
let visible_len = status_file_visible_len(area, self.repository_operation.is_some());
if visible_len == 0 {
self.file_scroll = self.selected_file;
return;
Expand Down Expand Up @@ -1490,8 +1494,17 @@ fn repo_list(app: &App) -> List<'_> {
}

fn status_panel(app: &App, area: Rect) -> Paragraph<'_> {
let visible_len = status_file_visible_len(area);
let visible_len = status_file_visible_len(area, app.repository_operation.is_some());
let mut lines = vec![Line::from(branch_summary(app.branch.as_ref()))];
if let Some(operation) = app.repository_operation {
lines.push(
Line::from(format!("{} IN PROGRESS", operation_label(operation))).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
);
}
if app.files.is_empty() {
lines.push(Line::from("working tree clean or unavailable"));
} else {
Expand Down Expand Up @@ -1533,6 +1546,13 @@ fn branch_summary(branch: Option<&BranchState>) -> String {
)
}

fn operation_label(operation: RepositoryOperation) -> &'static str {
match operation {
RepositoryOperation::Merge => "MERGE",
RepositoryOperation::Rebase => "REBASE",
}
}

fn branch_name(branch: &BranchState) -> Result<String, String> {
match &branch.head {
Head::Branch(name) if !branch.unborn => Ok(name.clone()),
Expand Down Expand Up @@ -2432,8 +2452,8 @@ fn validate_open_pull_request_plan(
Ok(())
}

fn status_file_visible_len(area: Rect) -> usize {
status_visible_len(area).saturating_sub(1)
fn status_file_visible_len(area: Rect, has_operation_banner: bool) -> usize {
status_visible_len(area).saturating_sub(1 + usize::from(has_operation_banner))
}

fn details_panel(app: &App) -> Paragraph<'_> {
Expand Down Expand Up @@ -5508,6 +5528,12 @@ mod tests {
);
}

#[test]
fn operation_labels_are_explicit() {
assert_eq!(operation_label(RepositoryOperation::Merge), "MERGE");
assert_eq!(operation_label(RepositoryOperation::Rebase), "REBASE");
}

#[test]
fn viewport_uses_desktop_panels_when_roomy() {
let viewport = Viewport::split(Rect::new(0, 0, 120, 40));
Expand Down Expand Up @@ -5630,7 +5656,7 @@ mod tests {
let mut app = App::new();
app.focus = Focus::Status;
app.last_viewport.status = Rect::new(0, 0, 30, 5);
let visible_len = status_file_visible_len(app.last_viewport.status);
let visible_len = status_file_visible_len(app.last_viewport.status, false);
app.files = (0..visible_len + 5)
.map(|index| FileRow {
path: std::path::PathBuf::from(format!("file-{index}.txt")),
Expand Down