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
22 changes: 16 additions & 6 deletions src/git.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Running `git` in a directory.

use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::{Command, Output};

use anyhow::{Context, Result, bail};

Expand All @@ -23,11 +23,7 @@ impl Git {
/// Run `git <args>` and return its trimmed stdout, failing with git's
/// stderr if it exits non-zero.
pub fn run(&self, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(&self.dir)
.output()
.with_context(|| format!("could not run git {}", args.join(" ")))?;
let output = self.output(args)?;
if !output.status.success() {
bail!(
"git {} failed: {}",
Expand All @@ -37,4 +33,18 @@ impl Git {
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

/// Run `git <args>` and report whether it exited zero, for commands whose
/// exit status is the answer.
pub fn succeeds(&self, args: &[&str]) -> Result<bool> {
Ok(self.output(args)?.status.success())
}

fn output(&self, args: &[&str]) -> Result<Output> {
Command::new("git")
.args(args)
.current_dir(&self.dir)
.output()
.with_context(|| format!("could not run git {}", args.join(" ")))
}
}
14 changes: 14 additions & 0 deletions src/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,17 @@ pub fn fresh(issue: &IssueUrl, base: &str, branch: &str) -> String {
number = issue.number,
)
}

/// The conflict Repair prompt, for a merge of the Base branch left in progress
/// with conflicts.
pub fn conflict_repair(issue: &IssueUrl, base: &str, branch: &str, pr_url: &str) -> String {
format!(
"/thirdshift:resolving-merge-conflicts\n\
\n\
A merge of origin/{base} into {branch} is in progress in this worktree and has conflicts.\n\
{branch} implements {url}; its pull request is {pr_url}.\n\
\n\
Resolve the conflicts, finish the merge, and push {branch}. Do not rebase or force-push.\n",
url = issue.url,
)
}
29 changes: 20 additions & 9 deletions src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::plugin::Plugin;
use crate::preflight;
use crate::prompt;
use crate::session;
use crate::worktree::Worktree;
use crate::worktree::{Merge, Worktree};

/// Take `issue` to a PR and return the PR's URL. The worktree, the local
/// Issue branch and the plugin directory are gone when this returns.
Expand All @@ -22,14 +22,15 @@ pub fn run(issue: &IssueUrl) -> Result<String> {

let worktree = Worktree::create_fresh(&launch, &issue.repo, &branch, &base)?;
let plugin = Plugin::write()?;
let log = session::log_path(issue, &timestamp, "implement")?;
session::run(
worktree.path(),
plugin.path(),
&prompt::fresh(issue, &base, &branch),
&log,
)?;
worktree.git().run(&["push", "origin", &branch])?;
// Every session runs in the worktree with the plugin loaded, logged as
// `kind` under the Run's timestamp.
let run_session = |kind: &str, prompt: &str| -> Result<()> {
let log = session::log_path(issue, &timestamp, kind)?;
session::run(worktree.path(), plugin.path(), prompt, &log)
};

run_session("implement", &prompt::fresh(issue, &base, &branch))?;
worktree.push()?;

let pr = github::pull_request_for(issue, &branch)?.context("no PR found")?;
if pr.state != "OPEN" {
Expand All @@ -38,5 +39,15 @@ pub fn run(issue: &IssueUrl) -> Result<String> {
if pr.base != base {
bail!("PR targets {}, not {base}", pr.base);
}

// Keep the PR mergeable: merge the Base branch, never rebase.
if worktree.merge_base_branch(&base)? == Merge::Conflicted {
run_session(
"repair-1",
&prompt::conflict_repair(issue, &base, &branch, &pr.url),
)?;
worktree.ensure_base_branch_merged(&base)?;
}
worktree.push()?;
Ok(pr.url)
}
53 changes: 50 additions & 3 deletions src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use anyhow::{Context, Result, bail};

use crate::git::Git;

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

pub struct Worktree {
launch: Git,
branch: String,
Expand Down Expand Up @@ -44,8 +53,46 @@ impl Worktree {
self.git.dir()
}

pub fn git(&self) -> &Git {
&self.git
/// Push the Issue branch to origin (a no-op if it is already there).
pub fn push(&self) -> Result<()> {
self.git.run(&["push", "origin", &self.branch])?;
Ok(())
}

/// Fetch `origin/<base>` and merge it into the Issue branch: a merge,
/// never a rebase, so pushing it is always a fast-forward. `--ff` keeps a
/// user's `merge.ff = only` from turning a clean merge into an error.
pub fn merge_base_branch(&self, base: &str) -> Result<Merge> {
self.git.run(&["fetch", "origin", base])?;
match self
.git
.run(&["merge", "--no-edit", "--ff", &format!("origin/{base}")])
{
Ok(_) => Ok(Merge::Clean),
Err(_) if self.merge_in_progress()? => Ok(Merge::Conflicted),
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<()> {
if self.merge_in_progress()? {
bail!("the merge of origin/{base} is still in progress");
}
let upstream = format!("origin/{base}");
if !self
.git
.succeeds(&["merge-base", "--is-ancestor", &upstream, "HEAD"])?
{
bail!("{upstream} is not merged into {}", self.branch);
}
Ok(())
}

fn merge_in_progress(&self) -> Result<bool> {
self.git
.succeeds(&["rev-parse", "-q", "--verify", "MERGE_HEAD"])
}
}

Expand Down
22 changes: 17 additions & 5 deletions tests/fakes/claude
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
"""Fake `claude` for the thirdshift test harness.

Each call appends a record to the JSON list in $FAKE_CLAUDE_RECORD: its argv,
prompt, working directory, the branch checked out there, and a snapshot of the
prompt, working directory, the branch checked out there, whether a merge is in
progress there, and a snapshot of the
--plugin-dir contents (the directory is gone by the time a test looks).

It then runs the per-test bash script $FAKE_CLAUDE_SCRIPT in the working
directory, emits a couple of stream-json lines, and exits with the script's
exit code.
It then runs a per-test bash script in the working directory, emits a couple
of stream-json lines, and exits with the script's exit code. The script for the
n-th call (1-based) is $FAKE_CLAUDE_SCRIPT with `.<n>` appended if that file
exists, else $FAKE_CLAUDE_SCRIPT itself.
"""

import json
Expand All @@ -32,6 +34,12 @@ def main():
branch = subprocess.run(
["git", "branch", "--show-current"], capture_output=True, text=True
).stdout.strip()
merging = (
subprocess.run(
["git", "rev-parse", "-q", "--verify", "MERGE_HEAD"], capture_output=True
).returncode
== 0
)

record_path = os.environ["FAKE_CLAUDE_RECORD"]
records = []
Expand All @@ -44,6 +52,7 @@ def main():
"prompt": argv[-1] if argv else None,
"cwd": os.getcwd(),
"branch": branch,
"merging": merging,
"plugin_dir": plugin_dir,
"plugin_files": plugin_snapshot(plugin_dir) if plugin_dir else {},
}
Expand All @@ -52,7 +61,10 @@ def main():
json.dump(records, f, indent=2)

print(json.dumps({"type": "system", "subtype": "init", "cwd": os.getcwd()}), flush=True)
code = subprocess.run(["bash", "-e", os.environ["FAKE_CLAUDE_SCRIPT"]]).returncode
script = os.environ["FAKE_CLAUDE_SCRIPT"]
if os.path.exists(f"{script}.{len(records)}"):
script = f"{script}.{len(records)}"
code = subprocess.run(["bash", "-e", script]).returncode
print(json.dumps({"type": "result", "subtype": "success", "is_error": code != 0}), flush=True)
sys.exit(code)

Expand Down
Loading
Loading