diff --git a/crates/bitbygit-git/src/lib.rs b/crates/bitbygit-git/src/lib.rs index 657dd20..fe46a35 100644 --- a/crates/bitbygit-git/src/lib.rs +++ b/crates/bitbygit-git/src/lib.rs @@ -481,10 +481,11 @@ impl Git { } fn in_progress_operation(&self) -> Result, 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"), ] { @@ -495,6 +496,19 @@ impl Git { Ok(None) } + fn repository_operation(&self) -> Result, 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 { let output = self.run_args(vec![ "rev-parse".to_owned(), @@ -617,7 +631,9 @@ impl Git { pub fn status(&self) -> Result { 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 { @@ -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, @@ -1244,6 +1275,7 @@ pub struct Remote { pub struct WorktreeStatus { pub branch: BranchState, pub entries: Vec, + pub operation: Option, } impl WorktreeStatus { @@ -1438,7 +1470,11 @@ fn parse_status_bytes(input: &[u8]) -> Result { } } - Ok(WorktreeStatus { branch, entries }) + Ok(WorktreeStatus { + branch, + entries, + operation: None, + }) } fn parse_ahead_behind(value: &str) -> Result<(u32, u32), GitError> { @@ -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(()) } @@ -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 { @@ -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> { + 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> { + 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> { + 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(()) } diff --git a/crates/bitbygit-tui/src/lib.rs b/crates/bitbygit-tui/src/lib.rs index db8f5b8..7dc5878 100644 --- a/crates/bitbygit-tui/src/lib.rs +++ b/crates/bitbygit-tui/src/lib.rs @@ -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}; @@ -131,6 +131,7 @@ pub struct App { selected_repo: usize, files: Vec, branch: Option, + repository_operation: Option, selected_file: usize, file_scroll: usize, details: String, @@ -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(), @@ -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) @@ -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}"); } @@ -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; @@ -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 { @@ -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 { match &branch.head { Head::Branch(name) if !branch.unborn => Ok(name.clone()), @@ -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<'_> { @@ -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)); @@ -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")),