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
8 changes: 4 additions & 4 deletions src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,10 +368,10 @@ impl RepairLoop<'_> {
if self.goal == Goal::Merged {
self.take_in_foreign_commits(run_session)?;
}
if worktree.merge_base_branch(base)? == Merge::Conflicted {
if let Merge::Conflicted(pending) = worktree.merge_base_branch(base)? {
let kind = self.budgets.next_repair("conflict")?;
run_session(&kind, &prompt::conflict_repair(issue, base, branch, pr_url))?;
worktree.ensure_base_branch_merged(base)?;
worktree.ensure_merged(&pending)?;
continue;
}
worktree.push()?;
Expand Down Expand Up @@ -427,15 +427,15 @@ impl RepairLoop<'_> {
for sha in &foreign {
progress::step(format_args!("merging new commit {sha} from {upstream}"));
}
if worktree.merge_new_commits()? == Merge::Conflicted {
if let Merge::Conflicted(pending) = worktree.merge_new_commits()? {
let kind = self
.budgets
.next_repair(&format!("conflict with new commits on {upstream}"))?;
run_session(
&kind,
&prompt::conflict_repair(issue, branch, branch, pr_url),
)?;
worktree.ensure_new_commits_merged()?;
worktree.ensure_merged(&pending)?;
}
let kind = self
.budgets
Expand Down
46 changes: 25 additions & 21 deletions src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,21 @@ use crate::progress;

/// How merging the Base branch, or new commits on origin, into the Issue
/// branch went.
#[derive(Debug, PartialEq, Eq)]
#[derive(Debug)]
pub enum Merge {
/// Merged, or nothing to merge.
Clean,
/// Conflicted; the merge is left in progress for a conflict Repair.
Conflicted,
Conflicted(PendingMerge),
}

/// A conflicted merge left for a conflict Repair: `upstream` as it was when
/// the merge began, pinned to `commit` because another Run's fetch may move
/// the shared remote-tracking ref before the Repair finishes.
#[derive(Debug)]
pub struct PendingMerge {
upstream: String,
commit: String,
}

pub struct Worktree {
Expand Down Expand Up @@ -182,28 +191,24 @@ impl Worktree {
fn merge(&self, upstream: &str) -> Result<Merge> {
match self.git.run(&["merge", "--no-edit", "--ff", upstream]) {
Ok(_) => Ok(Merge::Clean),
Err(_) if self.merge_in_progress()? => Ok(Merge::Conflicted),
Err(_) if self.merge_in_progress()? => Ok(Merge::Conflicted(PendingMerge {
upstream: upstream.to_string(),
commit: self.git.run(&["rev-parse", "MERGE_HEAD"])?,
})),
Err(error) => Err(error),
}
}

/// Fail unless `origin/<base>` is fully merged into the Issue branch, e.g.
/// after a conflict Repair that left the merge unfinished or aborted it.
pub fn ensure_base_branch_merged(&self, base: &str) -> Result<()> {
self.ensure_merged(&format!("origin/{base}"))
}

/// Like [`Worktree::ensure_base_branch_merged`], for the Issue branch as
/// last fetched from origin.
pub fn ensure_new_commits_merged(&self) -> Result<()> {
self.ensure_merged(&self.upstream())
}

fn ensure_merged(&self, upstream: &str) -> Result<()> {
/// Fail unless the commit of a conflicted merge is merged into the Issue
/// branch, e.g. after a conflict Repair that left the merge unfinished or
/// aborted it. The upstream may have moved on since; merging that is the
/// next round's work.
pub fn ensure_merged(&self, pending: &PendingMerge) -> Result<()> {
let upstream = &pending.upstream;
if self.merge_in_progress()? {
bail!("the merge of {upstream} is still in progress");
}
if !self.merged(upstream)? {
if !self.merged(&pending.commit)? {
bail!("{upstream} is not merged into {}", self.branch);
}
Ok(())
Expand All @@ -216,11 +221,10 @@ impl Worktree {
Ok(!self.merged(&format!("origin/{base}"))?)
}

/// Whether `upstream`, as last fetched, is fully merged into the Issue
/// branch.
fn merged(&self, upstream: &str) -> Result<bool> {
/// Whether `rev` is fully merged into the Issue branch.
fn merged(&self, rev: &str) -> Result<bool> {
self.git
.succeeds(&["merge-base", "--is-ancestor", upstream, "HEAD"])
.succeeds(&["merge-base", "--is-ancestor", rev, "HEAD"])
}

/// Whether a merge is in progress in the worktree.
Expand Down
31 changes: 31 additions & 0 deletions tests/mergeable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,37 @@ fn fails_when_the_conflict_repair_aborts_the_merge() {
);
}

#[test]
fn merges_a_base_branch_that_moved_again_during_the_conflict_repair() {
let scenario = Scenario::new();
scenario.agent_does(&format!(
"{AGENT_OPENS_PR}{}",
base_moves_on("feature.txt", "base feature")
));
// Another Run's fetch moves origin/main, which every worktree shares,
// after the Repair has resolved the merge it was given.
scenario.agent_does_in_session(
2,
&format!(
"{REPAIR_RESOLVES_CONFLICT}{}git fetch -q origin main\n",
base_moves_on("other.txt", "other")
),
);

let result = scenario.run(&[&scenario.issue_url(7)]);

assert_eq!(result.code, Some(0), "stderr: {}", result.stderr);
assert_eq!(scenario.claude_calls().len(), 2);
assert_eq!(
scenario.origin_git(&["rev-parse", "issue-7^2"]),
scenario.origin_git(&["rev-parse", "main"])
);
assert_eq!(
scenario.origin_git(&["show", "issue-7:feature.txt"]),
"feature\nbase feature\n"
);
}

/// While thirdshift waits for CI on each of the next `times` new head commits,
/// someone else pushes `file` with `content` to main. The script goes inside
/// single quotes, so it must not contain one.
Expand Down
Loading