use camino::{Utf8Path, Utf8PathBuf};
use serde::Serialize;
use crate::cli::integrate::IntegrateArgs;
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
use crate::integrate::{self, Entry, Ledger};
use crate::landing::Integration;
use crate::maintenance::{GIT_HOOK_VARS, last_line};
use crate::output::Output;
#[derive(Debug, Serialize)]
struct Report {
schema: &'static str,
mode: &'static str,
target: String,
integration: &'static str,
authority_source: &'static str,
branch: String,
trunk: String,
seat: String,
trunk_before: String,
#[serde(skip_serializing_if = "Option::is_none")]
trunk_commit: Option<String>,
steps: Vec<String>,
next: Vec<String>,
}
pub fn run(args: &IntegrateArgs) -> Result<(), RkError> {
let out = Output::new(args.json);
let target = &args.target;
let trunk = crate::config::trunk_of(target.as_std_path())?;
let (integration, authority_source) = authority(args, target)?;
if let Some(reason) = integrate::refuse_branch_name(&args.branch, &trunk) {
return Err(refuse(Reason::Usage, reason));
}
let git_dir = common_git_dir(target)?;
let seats = seats(target)?;
let seat = seat_for(&seats, &args.branch, target)?;
let dirty = is_dirty(&seat)?;
if dirty {
return Err(refuse(
Reason::StateDrift,
format!(
"{seat} has uncommitted changes, so the gate would judge a tree nobody reviewed"
),
));
}
match integration {
Integration::Forge => forge_path(args, out, &seat, &trunk, authority_source),
Integration::Local => local_path(
args,
out,
&LocalRun {
target,
git_dir: &git_dir,
seat: &seat,
seats: &seats,
trunk: &trunk,
authority_source,
},
),
}
}
struct LocalRun<'a> {
target: &'a Utf8Path,
git_dir: &'a Utf8Path,
seat: &'a Utf8Path,
seats: &'a [crate::worktree::Worktree],
trunk: &'a str,
authority_source: &'static str,
}
fn authority(
args: &IntegrateArgs,
target: &Utf8Path,
) -> Result<(Integration, &'static str), RkError> {
if args.local {
return Ok((Integration::Local, "flag"));
}
if args.forge {
return Ok((Integration::Forge, "flag"));
}
let recorded = crate::landing::manifest::load(target)?.map(|record| record.git.integration);
Ok(recorded.map_or(
(crate::landing::manifest::integration_forge(), "default"),
|mode| (mode, "record"),
))
}
fn forge_path(
args: &IntegrateArgs,
out: Output,
seat: &Utf8Path,
trunk: &str,
authority_source: &'static str,
) -> Result<(), RkError> {
let mut steps = Vec::new();
if args.apply {
let pushed = git(seat, &["push", "--set-upstream", "origin", &args.branch])?;
if !pushed.status.success() {
return Err(refuse(
Reason::RemoteConflict,
format!(
"pushing {} refused: {}",
args.branch,
last_line(&pushed.stderr)
),
));
}
steps.push(format!("pushed {} to origin", args.branch));
} else {
steps.push(format!("would push {} to origin", args.branch));
}
let next = request_commands(seat, trunk, &args.branch);
for line in &steps {
out.result_line(line);
}
out.emit(&Report {
schema: "rk.integrate/1",
mode: if args.apply { "apply" } else { "preview" },
target: args.target.to_string(),
integration: Integration::Forge.as_str(),
authority_source,
branch: args.branch.clone(),
trunk: trunk.to_owned(),
seat: seat.to_string(),
trunk_before: String::new(),
trunk_commit: None,
steps,
next: next.clone(),
})?;
out.next(&next);
Ok(())
}
fn request_commands(seat: &Utf8Path, trunk: &str, branch: &str) -> Vec<String> {
let remote = git(seat, &["remote", "get-url", "origin"])
.ok()
.filter(|answer| answer.status.success())
.map(|answer| String::from_utf8_lossy(&answer.stdout).trim().to_owned())
.unwrap_or_default();
let create = if remote.contains("gitlab") {
format!(
"glab mr create --source-branch {branch} --target-branch {trunk} --squash-before-merge"
)
} else {
format!("gh pr create --base {trunk} --head {branch}")
};
vec![
create,
"the title is the trunk's commit message, so it is a scoped Conventional Commit".to_owned(),
"the body is yours; rk message --check --kind body judges it before you post it".to_owned(),
]
}
fn preview(args: &IntegrateArgs, out: Output, run: &LocalRun<'_>) -> Result<(), RkError> {
let mut steps = Vec::new();
let trunk_before = rev_parse(run.seat, run.trunk)?;
warn_if_uncounted(
out,
run,
args.message.as_deref().unwrap_or_default(),
&[&format!("{trunk_before}...{}", args.branch)],
true,
);
steps.push("no fetch and no lock: a preview refreshes nothing".to_owned());
steps.push(format!(
"would rebase {} onto {}, or onto whatever the fetch brings",
args.branch,
integrate::short(&trunk_before)
));
steps.push("would run the manual stage in the seat".to_owned());
steps.push(format!("would write one squash commit onto {}", run.trunk));
report(
args,
out,
run,
&trunk_before,
None,
steps,
&[format!(
"rk integrate {} --target {} --apply performs it",
args.branch, run.target
)],
)
}
fn local_path(args: &IntegrateArgs, out: Output, run: &LocalRun<'_>) -> Result<(), RkError> {
let message = args.message.as_deref().unwrap_or_default();
refuse_message(run.seat, message)?;
let mut steps = Vec::new();
if !args.apply {
return preview(args, out, run);
}
let _held = crate::landing::lock::acquire(run.git_dir)?;
let ledger_path = run.git_dir.join(integrate::LEDGER_PATH);
let mut ledger = read_ledger(&ledger_path)?;
refresh_trunk(run, &mut steps)?;
let trunk_before = rev_parse(run.seat, run.trunk)?;
let rebased = git(run.seat, &["rebase", &trunk_before])?;
if !rebased.status.success() {
let _ = git(run.seat, &["rebase", "--abort"]);
return Err(refuse(
Reason::StateDrift,
format!(
"{} does not rebase onto {} cleanly: {}",
args.branch,
integrate::short(&trunk_before),
last_line(&rebased.stderr)
),
));
}
steps.push(format!(
"rebased {} onto {}",
args.branch,
integrate::short(&trunk_before)
));
let branch_tip = rev_parse(run.seat, &args.branch)?;
warn_if_uncounted(out, run, message, &[&trunk_before, &branch_tip], false);
let gate = gate(run.seat)?;
if !gate.status.success() {
out.child_passthrough(crate::events::ChildStream::Stderr, &gate.stderr);
out.child_passthrough(crate::events::ChildStream::Stdout, &gate.stdout);
return Err(refuse(
Reason::StateDrift,
format!(
"the manual stage failed in {}, so nothing reached {}",
run.seat, run.trunk
),
));
}
steps.push("the manual stage passed".to_owned());
let now = rev_parse(run.seat, &args.branch)?;
if now != branch_tip {
return Err(refuse(
Reason::StateDrift,
format!(
"{} moved from {} to {} while the gate ran, so the gate judged a tree that is no longer the branch's",
args.branch,
integrate::short(&branch_tip),
integrate::short(&now)
),
));
}
let commit = build_squash(run, &branch_tip, &trunk_before, message)?;
ledger.record(Entry {
branch: args.branch.clone(),
branch_tip,
trunk_commit: commit.clone(),
at: crate::landing::manifest::now(),
});
write_ledger(&ledger_path, &ledger)?;
publish(run, &commit, &trunk_before)?;
steps.push(format!(
"{} now carries {}",
run.trunk,
integrate::short(&commit)
));
steps.push("recorded the integration".to_owned());
report(
args,
out,
run,
&trunk_before,
Some(commit),
steps,
&[
format!(
"git -C {} push origin {} pushes the trunk; it is a fast-forward and never a force",
run.target, run.trunk
),
format!(
"rk worktree prune --target {} retires the seat, then its branch",
run.target
),
],
)
}
fn build_squash(
run: &LocalRun<'_>,
branch_tip: &str,
parent: &str,
message: &str,
) -> Result<String, RkError> {
let tree = rev_parse(run.seat, &format!("{branch_tip}^{{tree}}"))?;
let built = git(
run.seat,
&["commit-tree", &tree, "-p", parent, "-m", message],
)?;
if !built.status.success() {
return Err(refuse(
Reason::Internal,
format!(
"the squash commit could not be built: {}",
last_line(&built.stderr)
),
));
}
Ok(String::from_utf8_lossy(&built.stdout).trim().to_owned())
}
fn refresh_trunk(run: &LocalRun<'_>, steps: &mut Vec<String>) -> Result<(), RkError> {
let named = git(run.seat, &["remote", "get-url", "origin"])?;
if !named.status.success() {
steps.push("no origin remote; the local trunk stands alone".to_owned());
return Ok(());
}
let fetched = git(run.seat, &["fetch", "--quiet", "origin"])?;
if !fetched.status.success() {
return Err(refuse(
Reason::ForgeTemporary,
format!(
"origin is configured and did not answer, so the trunk could not be refreshed: {}",
last_line(&fetched.stderr)
),
));
}
steps.push("fetched origin".to_owned());
let Some(remote) = rev_parse(run.seat, &format!("refs/remotes/origin/{}", run.trunk)).ok()
else {
steps.push(format!(
"origin carries no {} yet; the local trunk stands alone",
run.trunk
));
return Ok(());
};
let local = rev_parse(run.seat, run.trunk)?;
let state = integrate::trunk_state(
local == remote,
is_ancestor(run.seat, &local, &remote),
is_ancestor(run.seat, &remote, &local),
);
if let Some(reason) = integrate::refuse_trunk_state(state) {
return Err(refuse(Reason::RemoteConflict, reason));
}
match state {
integrate::TrunkState::Behind => {
fast_forward_trunk(run, &remote, &local)?;
steps.push(format!(
"fast-forwarded {} to origin/{}",
run.trunk, run.trunk
));
}
integrate::TrunkState::Ahead => {
steps.push(format!(
"{} carries integrations nobody pushed yet",
run.trunk
));
}
integrate::TrunkState::Level | integrate::TrunkState::Diverged => {}
}
Ok(())
}
fn fast_forward_trunk(run: &LocalRun<'_>, to: &str, from: &str) -> Result<(), RkError> {
trunk_move(run, to, from, "fast-forward")
}
fn publish(run: &LocalRun<'_>, commit: &str, expected: &str) -> Result<(), RkError> {
trunk_move(run, commit, expected, "integration")
}
fn trunk_move(run: &LocalRun<'_>, to: &str, expected: &str, what: &str) -> Result<(), RkError> {
let holder = run
.seats
.iter()
.find(|seat| seat.branch.as_deref() == Some(run.trunk));
if let Some(holder) = holder {
if holder.head != expected {
return Err(moved(run.trunk, expected, &holder.head));
}
if is_dirty(&holder.path)? {
return Err(refuse(
Reason::StateDrift,
format!(
"{} has the trunk checked out and carries uncommitted changes, so the {what} would leave it behind its own HEAD",
holder.path
),
));
}
let merged = git(&holder.path, &["merge", "--ff-only", to])?;
if !merged.status.success() {
return Err(refuse(
Reason::StateDrift,
format!(
"the {what} is not a fast-forward of {}: {}",
holder.path,
last_line(&merged.stderr)
),
));
}
return Ok(());
}
let reference = format!("refs/heads/{}", run.trunk);
let swapped = git(run.seat, &["update-ref", &reference, to, expected])?;
if !swapped.status.success() {
let now = rev_parse(run.seat, run.trunk).unwrap_or_else(|_| "an unreadable tip".to_owned());
return Err(moved(run.trunk, expected, &now));
}
Ok(())
}
fn moved(trunk: &str, expected: &str, now: &str) -> RkError {
RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
integrate::refuse_moved_trunk(expected, now).unwrap_or_else(|| {
format!(
"{trunk} could not be moved and stands at {}",
integrate::short(now)
)
}),
)
.target_state(format!(
"{trunk} stands where it stood; the staged evidence names a commit it does not reach, which every prune ignores"
)),
)
}
fn warn_if_uncounted(
out: Output,
run: &LocalRun<'_>,
message: &str,
range: &[&str],
previewed: bool,
) {
let Some(intent) = integrate::release_intent(message) else {
return;
};
let rust_release = crate::landing::manifest::load(run.target)
.ok()
.flatten()
.is_some_and(|record| {
let release = &record.profile.release;
release.mode == crate::profile::ReleaseMode::Automatic
&& release.driver.as_deref() == Some("rust")
});
if !rust_release {
return;
}
let mut diff = vec!["diff", "--name-only", "--no-renames", "-z"];
diff.extend_from_slice(range);
let Ok(changed) = git(run.seat, &diff) else {
return;
};
if !changed.status.success() {
return;
}
let changed = String::from_utf8_lossy(&changed.stdout).into_owned();
let changed: Vec<&str> = changed
.split('\0')
.filter(|path| !path.is_empty())
.collect();
let root_manifest = run.seat.as_std_path().join("Cargo.toml");
let probe = crate::cargo_package::probe(&root_manifest, &["--locked", "--offline"], |args| {
cargo_in(run.seat, args)
});
let Ok(crate::cargo_package::Probe::Listed(listing)) = probe else {
return;
};
if !listing.touched_by(changed.iter().copied()) {
out.warn(integrate::uncounted_release(intent, previewed));
}
}
fn cargo_in(seat: &Utf8Path, args: &[&str]) -> Result<crate::cargo_package::Answer, ()> {
let mut command = std::process::Command::new("cargo");
for var in GIT_HOOK_VARS {
command.env_remove(var);
}
command
.current_dir(seat.as_std_path())
.args(args)
.stdin(std::process::Stdio::null())
.output()
.map(|output| crate::cargo_package::Answer {
success: output.status.success(),
stdout: output.stdout,
stderr: output.stderr,
})
.map_err(|_| ())
}
fn gate(seat: &Utf8Path) -> Result<std::process::Output, RkError> {
let mut command = std::process::Command::new("pre-commit");
for var in GIT_HOOK_VARS {
command.env_remove(var);
}
command
.current_dir(seat.as_std_path())
.args(["run", "--hook-stage", "manual", "--all-files"])
.output()
.map_err(|source| {
RkError::subprocess(
Diagnostic::new(
Reason::SubprocessSpawn,
format!("pre-commit did not run in {seat}: {source}"),
)
.expected(
"pre-commit on PATH, which is what installs and runs this project's hooks",
)
.action("install pre-commit, or enter the project's devshell, and run it again")
.target_state("unchanged"),
)
})
}
fn report(
args: &IntegrateArgs,
out: Output,
run: &LocalRun<'_>,
trunk_before: &str,
trunk_commit: Option<String>,
steps: Vec<String>,
next: &[String],
) -> Result<(), RkError> {
for step in &steps {
out.result_line(step);
}
out.emit(&Report {
schema: "rk.integrate/1",
mode: if args.apply { "apply" } else { "preview" },
target: run.target.to_string(),
integration: Integration::Local.as_str(),
authority_source: run.authority_source,
branch: args.branch.clone(),
trunk: run.trunk.to_owned(),
seat: run.seat.to_string(),
trunk_before: trunk_before.to_owned(),
trunk_commit,
steps,
next: next.to_vec(),
})?;
out.next(next);
Ok(())
}
const SHAPE: &str = "a scoped Conventional Commit: <type>(<scope>): <description>";
const TYPES: [&str; 11] = [
"build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test",
];
fn refuse_message(target: &Utf8Path, text: &str) -> Result<(), RkError> {
let subject = text.lines().next().unwrap_or("").trim();
if subject.is_empty() {
return Err(refuse(
Reason::Usage,
"a local integration writes the trunk's commit message, so --message is required",
));
}
if let Some(reason) = misshapen_subject(subject) {
return Err(refuse(Reason::Usage, reason));
}
let (findings, _) = crate::commands::message::judge(
text,
crate::cli::message::MessageKind::Commit,
target,
subject,
crate::commands::message::exempt_title(subject),
);
if findings.is_empty() {
return Ok(());
}
let named: Vec<String> = findings
.iter()
.map(|finding| format!("{}:{} {}", finding.class, finding.line, finding.detail))
.collect();
Err(refuse(
Reason::Usage,
format!(
"the trunk message carries {} finding{} the landed commit-msg stage would refuse, and git commit-tree fires no hook: {}",
findings.len(),
if findings.len() == 1 { "" } else { "s" },
named.join("; ")
),
))
}
#[must_use]
fn misshapen_subject(subject: &str) -> Option<String> {
let Some((head, description)) = subject.split_once(": ") else {
return Some(format!("'{subject}' is not {SHAPE}"));
};
if description.trim().is_empty() {
return Some(format!(
"'{subject}' is not {SHAPE}: it states no description"
));
}
let head = head.strip_suffix('!').unwrap_or(head);
let Some((kind, scope)) = head.split_once('(') else {
return Some(format!("'{subject}' is not {SHAPE}: it names no scope"));
};
let Some(scope) = scope.strip_suffix(')') else {
return Some(format!("'{subject}' is not {SHAPE}: its scope is unclosed"));
};
if !TYPES.contains(&kind) {
return Some(format!(
"'{kind}' is not a Conventional Commit type; the types are: {}",
TYPES.join(", ")
));
}
if !crate::projection::scope_is_shaped(scope) {
return Some(format!(
"the scope '{scope}' is outside {}: lowercase letters, digits, and _ . / -",
crate::projection::SCOPE_SHAPE
));
}
None
}
fn seats(target: &Utf8Path) -> Result<Vec<crate::worktree::Worktree>, RkError> {
let listed = git(target, &["worktree", "list", "--porcelain", "-z"])?;
if !listed.status.success() {
return Err(RkError::missing(
Diagnostic::new(
Reason::TargetNotFound,
format!("target {target} is not a git repository"),
)
.expected("a repository whose worktrees git can list"),
));
}
crate::worktree::parse_worktrees(&listed.stdout)
.map_err(|detail| refuse(Reason::PrerequisiteUnmet, detail))
}
fn seat_for(
seats: &[crate::worktree::Worktree],
branch: &str,
target: &Utf8Path,
) -> Result<Utf8PathBuf, RkError> {
seats
.iter()
.find(|seat| seat.branch.as_deref() == Some(branch))
.map(|seat| seat.path.clone())
.ok_or_else(|| {
refuse(
Reason::StateDrift,
format!(
"no worktree of {target} has {branch} checked out; rk worktree add {branch} --apply seats it"
),
)
})
}
fn is_dirty(seat: &Utf8Path) -> Result<bool, RkError> {
let status = git(seat, &["status", "--porcelain"])?;
Ok(!status.stdout.is_empty())
}
fn rev_parse(seat: &Utf8Path, revision: &str) -> Result<String, RkError> {
let answer = git(seat, &["rev-parse", "--verify", "--quiet", revision])?;
if !answer.status.success() {
return Err(refuse(
Reason::StateDrift,
format!("{revision} does not resolve in {seat}"),
));
}
Ok(String::from_utf8_lossy(&answer.stdout).trim().to_owned())
}
fn is_ancestor(seat: &Utf8Path, ancestor: &str, descendant: &str) -> bool {
git(seat, &["merge-base", "--is-ancestor", ancestor, descendant])
.is_ok_and(|answer| answer.status.success())
}
fn common_git_dir(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
let answer = git(
target,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
)?;
if !answer.status.success() {
return Err(RkError::missing(
Diagnostic::new(
Reason::TargetNotFound,
format!("target {target} is not a git repository"),
)
.expected("a repository whose common git directory git can name"),
));
}
let path = String::from_utf8_lossy(&answer.stdout).trim().to_owned();
Utf8PathBuf::from_path_buf(std::path::PathBuf::from(path)).map_err(|path| {
refuse(
Reason::PrerequisiteUnmet,
format!("the common git directory {} is not UTF-8", path.display()),
)
})
}
fn read_ledger(path: &Utf8Path) -> Result<Ledger, RkError> {
match std::fs::read_to_string(path) {
Ok(text) => {
Ledger::parse(&text).map_err(|detail| refuse(Reason::UnsupportedSchema, detail))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Ledger::default()),
Err(error) => Err(RkError::Io(error)),
}
}
fn write_ledger(path: &Utf8Path, ledger: &Ledger) -> Result<(), RkError> {
let text = ledger
.render()
.map_err(|detail| refuse(Reason::Internal, detail))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
crate::atomic::write(path.as_std_path(), text.as_bytes())?;
Ok(())
}
fn refuse(reason: Reason, message: impl Into<String>) -> RkError {
RkError::refusal(
Diagnostic::new(reason, message).target_state("unchanged; the trunk stands where it stood"),
)
}
fn git(at: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
let mut command = std::process::Command::new(crate::probes::git_bin());
for var in GIT_HOOK_VARS {
command.env_remove(var);
}
command
.arg("-C")
.arg(at.as_std_path())
.args(args)
.output()
.map_err(|source| {
RkError::subprocess(
Diagnostic::new(
Reason::SubprocessSpawn,
format!("git did not run in {at}: {source}"),
)
.target_state("unchanged"),
)
})
}
#[cfg(test)]
mod tests {
use camino::Utf8Path;
use super::{misshapen_subject, refuse_message};
#[test]
fn a_trunk_subject_is_held_to_the_landed_convention() {
assert_eq!(misshapen_subject("feat(integrate): land the verb"), None);
assert_eq!(misshapen_subject("feat(a/b)!: break it"), None);
assert!(
misshapen_subject("land the verb")
.expect("an unscoped subject refuses")
.contains("Conventional Commit")
);
assert!(
misshapen_subject("feat: land the verb")
.expect("a missing scope refuses")
.contains("names no scope")
);
assert!(
misshapen_subject("feat(integrate): ")
.expect("an empty description refuses")
.contains("no description")
);
assert!(
misshapen_subject("wat(integrate): land it")
.expect("an unknown type refuses")
.contains("not a Conventional Commit type")
);
assert!(
misshapen_subject("feat(Integrate): land it")
.expect("a misshapen scope refuses")
.contains("outside")
);
}
#[test]
fn a_trunk_body_is_judged_by_the_one_message_owner() {
let dir = tempfile::tempdir().expect("a scratch dir exists");
let target = Utf8Path::from_path(dir.path()).expect("utf-8");
refuse_message(target, "feat(integrate): land the verb\n\nThe context.\n")
.expect("a clean message passes");
let error = refuse_message(
target,
"feat(integrate): land the verb\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n",
)
.expect_err("agent attribution refuses")
.to_string();
assert!(error.contains("attribution"), "{error}");
assert!(error.contains("commit-tree fires no hook"), "{error}");
let error = refuse_message(
target,
"feat(integrate): land the verb\n\nSee .draft/plan.md for the rest.\n",
)
.expect_err("an internal path refuses")
.to_string();
assert!(error.contains("internal-path"), "{error}");
let error = refuse_message(target, "")
.expect_err("an empty message refuses")
.to_string();
assert!(error.contains("--message is required"), "{error}");
}
}