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
18 changes: 11 additions & 7 deletions src/commands/list/collect/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ use std::sync::Arc;

use anyhow::Context;
use worktrunk::git::{
ErrorExt, IntegrationTargets, LineDiff, RefSnapshot, Repository, select_comparison_base,
ErrorExt, InProgressOperation, IntegrationTargets, LineDiff, RefSnapshot, Repository,
select_comparison_base,
};

use super::super::ci_status::{CiBranchName, PrStatus};
Expand Down Expand Up @@ -993,15 +994,18 @@ fn first_line(s: &str) -> String {
}

/// Detect if a worktree is in the middle of a git operation (rebase/merge).
///
/// The gutter symbols cover the two operations `wt merge` itself performs, so
/// the states with no symbol of their own (cherry-pick, revert, bisect) report
/// as `None` here — they still block the commit-replaying commands, via
/// `ensure_no_operation_in_progress`.
pub(crate) fn detect_active_git_operation(
wt: &worktrunk::git::WorkingTree<'_>,
) -> ActiveGitOperation {
if wt.is_rebasing().unwrap_or(false) {
ActiveGitOperation::Rebase
} else if wt.is_merging().unwrap_or(false) {
ActiveGitOperation::Merge
} else {
ActiveGitOperation::None
match wt.operation_in_progress() {
Ok(Some(InProgressOperation::Rebase)) => ActiveGitOperation::Rebase,
Ok(Some(InProgressOperation::Merge)) => ActiveGitOperation::Merge,
_ => ActiveGitOperation::None,
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/commands/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> {
let env = CommandEnv::for_action(config)?;
let repo = &env.repo;
let config = &env.config;
// Ahead of the branch check: mid-rebase and mid-bisect both detach HEAD, so
// an unguarded merge blames the detached HEAD and points at `git switch`,
// which abandons the operation instead of finishing it.
repo.ensure_no_operation_in_progress("merge")?;
// Merge requires being on a branch (can't merge from detached HEAD)
let current_branch = env.require_branch("merge")?.to_string();

Expand Down
14 changes: 9 additions & 5 deletions src/commands/step/rebase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use anyhow::Context;
use color_print::cformat;
use worktrunk::git::{ErrorExt, Repository};
use worktrunk::git::{ErrorExt, InProgressOperation, Repository};
use worktrunk::styling::{eprintln, progress_message, success_message};

use super::super::repository_ext::RepositoryCliExt;
Expand All @@ -19,6 +19,11 @@ pub enum RebaseResult {
pub fn handle_rebase(target: Option<&str>) -> anyhow::Result<RebaseResult> {
let repo = Repository::current()?;

// Refuse before reading ancestry: a worktree stopped mid-rebase has HEAD
// detached on a linear extension of the target, so the up-to-date check
// below would report success over a tree that still holds conflict markers.
repo.ensure_no_operation_in_progress("rebase")?;

// Get and validate target ref (any commit-ish for rebase)
let integration_target = repo.require_target_ref(target)?;
// #3519: when the branch's history extends past the local target into the
Expand Down Expand Up @@ -53,14 +58,13 @@ pub fn handle_rebase(target: Option<&str>) -> anyhow::Result<RebaseResult> {

// If rebase failed, classify the failure (interrupt vs conflict vs other).
if let Err(e) = rebase_result {
let is_rebasing = repo
.worktree_state()?
.is_some_and(|s| s.starts_with("REBASING"));
let state = repo.operation_in_progress()?;
let is_rebasing = matches!(state, Some(InProgressOperation::Rebase));
return Err(classify_rebase_failure(e, is_rebasing, &integration_target));
}

// Verify rebase completed successfully (safety check for edge cases)
if repo.worktree_state()?.is_some() {
if repo.operation_in_progress()?.is_some() {
return Err(worktrunk::git::GitError::RebaseConflict {
target_branch: integration_target,
git_output: String::new(),
Expand Down
42 changes: 27 additions & 15 deletions src/git/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,16 @@ pub enum GitError {
DetachedHead {
action: Option<String>,
},
/// The worktree is partway through a git operation, so a command that
/// replays commits (`wt merge`, `wt step rebase`) refuses to start.
///
/// Carries no operation and offers no remedy: `git status` names which
/// operation is open and how to finish it, so restating either here only
/// adds a line that can drift from what git accepts.
OperationInProgress {
/// The action the user asked for ("merge", "rebase").
action: String,
},
UncommittedChanges {
action: Option<String>,
/// Branch name (for multi-worktree operations)
Expand Down Expand Up @@ -631,6 +641,10 @@ impl GitError {
None => "Not on a branch (detached HEAD)".to_string(),
},

GitError::OperationInProgress { action } => {
cformat!("Cannot {action}: a git operation is already in progress")
}

GitError::UncommittedChanges { action, branch, .. } => match (action, branch) {
(Some(action), Some(b)) => {
cformat!("Cannot {action}: <bold>{b}</> has uncommitted changes")
Expand Down Expand Up @@ -862,6 +876,11 @@ impl GitError {
)
}

GitError::OperationInProgress { .. } => {
let title = self.title();
write!(f, "{}", error_message(&title))
}

GitError::UncommittedChanges {
branch,
force_hint,
Expand Down Expand Up @@ -1192,21 +1211,18 @@ impl GitError {
}
}

// Git's own stderr carries the way out — `--continue`, `--skip`,
// `--abort` — so it is forwarded in the gutter rather than
// paraphrased. Nothing stands in for it when it is empty: the
// rebase is still on disk, and `git status` names the remedy in
// git's words for whichever operation is actually open.
GitError::RebaseConflict { git_output, .. } => {
let title = self.title();
write!(f, "{}", error_message(&title))?;
if !git_output.is_empty() {
write!(f, "\n{}", format_with_gutter(git_output, None))
} else {
write!(
f,
"\n{}\n{}",
hint_message(cformat!(
"To continue after resolving conflicts, run <underline>git rebase --continue</>"
)),
hint_message(cformat!("To abort, run <underline>git rebase --abort</>"))
)
write!(f, "\n{}", format_with_gutter(git_output, None))?;
}
Ok(())
}

GitError::NotRebased { target_branch } => {
Expand Down Expand Up @@ -2428,11 +2444,7 @@ mod tests {
target_branch: "main".into(),
git_output: "".into(),
};
assert_snapshot!(err.render(), @"
✗ Rebase onto main incomplete
↳ To continue after resolving conflicts, run git rebase --continue
↳ To abort, run git rebase --abort
");
assert_snapshot!(err.render(), @"✗ Rebase onto main incomplete");
}

#[test]
Expand Down
5 changes: 3 additions & 2 deletions src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ pub use remove::{
};
pub use repository::sha_cache;
pub use repository::{
Branch, BranchDiffSpec, CommitMessageDetail, IntegrationTargets, RefSnapshot, Repository,
ResolvedWorktree, TempIndex, WorkingTree, select_comparison_base, set_base_path,
Branch, BranchDiffSpec, CommitMessageDetail, InProgressOperation, IntegrationTargets,
RefSnapshot, Repository, ResolvedWorktree, TempIndex, WorkingTree, select_comparison_base,
set_base_path,
};
pub use url::parse_owner_repo;
pub use url::{GitRemoteUrl, GitRepoInfo, GitRepoProvider};
Expand Down
71 changes: 27 additions & 44 deletions src/git/repository/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ pub use diff::CommitMessageDetail;
pub use integration::{BranchDiffSpec, IntegrationTargets, select_comparison_base};
pub use ref_snapshot::RefSnapshot;
pub(super) use working_tree::path_to_logging_context;
pub use working_tree::{TempIndex, WorkingTree};
pub use working_tree::{InProgressOperation, TempIndex, WorkingTree};

// ============================================================================
// Repository Cache
Expand Down Expand Up @@ -1457,52 +1457,35 @@ impl Repository {
}
}

/// Get merge/rebase status for the worktree at this repository's discovery path.
pub fn worktree_state(&self) -> anyhow::Result<Option<String>> {
let git_dir = self.worktree_at(self.discovery_path()).git_dir()?;

// Check for merge
if git_dir.join("MERGE_HEAD").exists() {
return Ok(Some("MERGING".to_string()));
}
/// The git operation the worktree at this repository's discovery path is
/// partway through, if any. See [`WorkingTree::operation_in_progress`].
pub fn operation_in_progress(&self) -> anyhow::Result<Option<InProgressOperation>> {
self.worktree_at(self.discovery_path())
.operation_in_progress()
}

// Check for rebase. `rebase-merge` (interactive/merge backend) and
// `rebase-apply` (am backend) are mutually exclusive; probe each once.
let rebase_merge = git_dir.join("rebase-merge");
let rebase_apply = git_dir.join("rebase-apply");
if let Some(rebase_dir) = rebase_merge
.exists()
.then_some(rebase_merge)
.or_else(|| rebase_apply.exists().then_some(rebase_apply))
{
if let (Ok(msgnum), Ok(end)) = (
std::fs::read_to_string(rebase_dir.join("msgnum")),
std::fs::read_to_string(rebase_dir.join("end")),
) {
let current = msgnum.trim();
let total = end.trim();
return Ok(Some(format!("REBASING {}/{}", current, total)));
/// Fail when the worktree is already partway through a git operation.
///
/// A precondition for the commit-replaying commands (`wt step rebase`,
/// `wt merge`). Mid-rebase, HEAD is detached on a linear extension of the
/// target, so `is_rebased_onto` — and every
/// ancestry check like it — reads as "already up to date"; without this
/// gate a caller reports success over a tree that still holds conflict
/// markers. The other states are gated for the same reason rather than
/// their own symptom: a rebase started from any of them either compounds
/// the half-finished operation or dies inside git with its own plumbing
/// error, and neither tells the user what to do next.
///
/// The refusal names no operation, so nothing here has to track what git
/// calls each state or how to leave it; `git status` answers both.
pub fn ensure_no_operation_in_progress(&self, action: &str) -> anyhow::Result<()> {
match self.operation_in_progress()? {
Some(_) => Err(crate::git::GitError::OperationInProgress {
action: action.to_string(),
}

return Ok(Some("REBASING".to_string()));
}

// Check for cherry-pick
if git_dir.join("CHERRY_PICK_HEAD").exists() {
return Ok(Some("CHERRY-PICKING".to_string()));
}

// Check for revert
if git_dir.join("REVERT_HEAD").exists() {
return Ok(Some("REVERTING".to_string()));
.into()),
None => Ok(()),
}

// Check for bisect
if git_dir.join("BISECT_LOG").exists() {
return Ok(Some("BISECTING".to_string()));
}

Ok(None)
}

// =========================================================================
Expand Down
91 changes: 83 additions & 8 deletions src/git/repository/working_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,51 @@ fn has_initialized_submodules_from_status(status: &str) -> bool {
})
}

/// A git operation a worktree is partway through, detected from the state files
/// git writes under its git dir (`MERGE_HEAD`, `rebase-merge/`, `BISECT_LOG`, …).
///
/// Produced by [`WorkingTree::operation_in_progress`]. Detection only: it
/// reports what git left on disk and deliberately maps nothing to a remedy.
/// `git status` already names the operation and the way out of it, in git's own
/// words and git's own translations, including states worktrunk has no variant
/// for, so a table here would only be a second copy to keep current with git.
///
/// Structured rather than a display string so [`Rebase`](Self::Rebase) can be
/// recognized without matching on user-visible text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InProgressOperation {
Merge,
/// Rebase, from either backend. `git am` shares the am backend's
/// `rebase-apply` directory and lands here too, which costs nothing: the
/// only caller that cares is classifying a rebase worktrunk itself just
/// started, and this gate stops that from happening during an am session.
Rebase,
CherryPick,
Revert,
Bisect,
}

/// The operation a queued sequencer belongs to, or `None` when nothing is
/// queued.
///
/// `git cherry-pick`/`git revert` over several commits keep the remaining
/// instructions in `sequencer/todo`, one `<command> <sha> <subject>` line each,
/// and delete the directory once the sequence finishes or is aborted. Reading
/// the first line mirrors git's own `sequencer_get_last_command`, which is how
/// `git status` reports a sequence whose stopped pick was committed by hand.
///
/// Only cherry-pick and revert write this file — a rebase has its own todo
/// under `rebase-merge/` — so an unrecognized first word means the file isn't a
/// sequence worktrunk should read, and it reports nothing rather than guessing.
fn sequencer_operation(git_dir: &Path) -> Option<InProgressOperation> {
let todo = std::fs::read_to_string(git_dir.join("sequencer").join("todo")).ok()?;
match todo.split_whitespace().next()? {
"pick" => Some(InProgressOperation::CherryPick),
"revert" => Some(InProgressOperation::Revert),
_ => None,
}
}

/// Typed snapshot returned by [`WorkingTree::prewarm_info`].
///
/// Mirrors what the batched `git rev-parse` actually resolved so callers can
Expand Down Expand Up @@ -401,16 +446,46 @@ impl<'a> WorkingTree<'a> {
}
}

/// Check if a rebase is in progress.
pub fn is_rebasing(&self) -> anyhow::Result<bool> {
/// The git operation this worktree is partway through, if any.
///
/// Reads the state files git writes under the worktree's git dir, in the
/// order [`git status`](https://git-scm.com/docs/git-status) consults them,
/// so the answer tracks what git itself calls "in progress".
pub fn operation_in_progress(&self) -> anyhow::Result<Option<InProgressOperation>> {
let git_dir = self.git_dir()?;
Ok(git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists())
}

/// Check if a merge is in progress.
pub fn is_merging(&self) -> anyhow::Result<bool> {
let git_dir = self.git_dir()?;
Ok(git_dir.join("MERGE_HEAD").exists())
if git_dir.join("MERGE_HEAD").exists() {
return Ok(Some(InProgressOperation::Merge));
}

// `rebase-merge` (interactive/merge backend) and `rebase-apply` (am
// backend, also used by `git am`) are mutually exclusive; either one
// means commits are mid-replay.
if git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists() {
return Ok(Some(InProgressOperation::Rebase));
}

if git_dir.join("CHERRY_PICK_HEAD").exists() {
return Ok(Some(InProgressOperation::CherryPick));
}

if git_dir.join("REVERT_HEAD").exists() {
return Ok(Some(InProgressOperation::Revert));
}

// The two `_HEAD` files above exist only while a single pick is
// stopped: resolving one with `git commit` instead of `--continue`
// removes it and leaves the rest of the sequence queued, which git
// still reports as in progress.
if let Some(operation) = sequencer_operation(&git_dir) {
return Ok(Some(operation));
}

if git_dir.join("BISECT_LOG").exists() {
return Ok(Some(InProgressOperation::Bisect));
}

Ok(None)
}

/// Check if this is a linked worktree (vs the main worktree).
Expand Down
17 changes: 17 additions & 0 deletions tests/integration_tests/git_error_display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,23 @@ fn display_detached_head_no_action() {
assert_snapshot!("detached_head_no_action", err.render());
}

/// The refusal names the blocked action and defers to `git status` for what is
/// open and how to leave it, so no operation appears in the message at all.
#[test]
fn display_operation_in_progress() {
let rendered: Vec<String> = ["rebase", "merge"]
.into_iter()
.map(|action| {
GitError::OperationInProgress {
action: action.into(),
}
.render()
})
.collect();

assert_snapshot!("operation_in_progress", rendered.join("\n\n"));
}

#[test]
fn display_uncommitted_changes() {
let err = GitError::UncommittedChanges {
Expand Down
Loading
Loading