feat(auto-merge): merge additive branches, refuse everything else
ci / gates (push) Failing after 10s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped

Closes the branch pile-up: a catalogue branch that only adds notes now
merges into main by itself, so the work is actually in the vault rather
than waiting in a branch nobody opened.

Additive-only is measured from the diff, not assumed from the mission
type. Three conditions, all required: the type declares additive_only,
the run verified, and `git diff --name-status base...branch` contains
only A entries. A research harvest that somehow rewrote a hand-written
note is refused by the same check that lets its new notes through —
which is the case the test pins down, asserting README.md on main is
byte-identical afterwards.

Renames and deletes count as non-additive. A rename is a delete plus an
add and the delete half can destroy hand-written work.

Unknown merge_policy values fail closed to Never. A typo must not grant
auto-merge.

The diff is taken against FETCH_HEAD, freshly fetched, using `...` so an
unrelated commit landing on main meanwhile is not misread as ours. A
conflicted merge aborts and leaves the branch for a human rather than
wedging the checkout for the next run.

merge_reason is always populated and surfaced in the API: a branch that
quietly did not merge is indistinguishable from one never delivered.

399 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-03 11:33:41 -07:00
co-authored by Claude Opus 5
parent 3124fd3c8f
commit 9de2cf34e4
5 changed files with 412 additions and 6 deletions
+38 -6
View File
@@ -35,6 +35,11 @@ pub struct LibraryRun {
/// papers but could not push still has the PDFs and the checkmarks; the
/// notes are simply not on the forge yet.
pub pushed: bool,
/// Whether the branch was auto-merged into `main`.
pub merged: bool,
/// Always populated — a branch that quietly did not merge is
/// indistinguishable from one that was never delivered.
pub merge_reason: String,
pub error: Option<String>,
}
@@ -160,6 +165,8 @@ pub async fn run_to_vault(
harvest: total,
branch,
pushed: false,
merged: false,
merge_reason: "nothing new to push".into(),
error: None,
});
}
@@ -181,16 +188,41 @@ pub async fn run_to_vault(
let auth = mission_workspace::with_ambient_auth(clone_url);
let refspec = format!("HEAD:refs/heads/{branch}");
match git(&vault, &["push", &auth, &refspec]).await {
Ok(_) => Ok(LibraryRun {
harvest: total,
branch,
pushed: true,
error: None,
}),
Ok(_) => {
// A catalogue branch only ever adds notes under `60 Papers/`, so
// it qualifies for auto-merge — but the check is measured from the
// diff, not assumed from the mission type. Verified here means the
// run shelved something and errored on nothing.
let verified = total.healthy() && !total.shelved.is_empty();
let merge = crate::auto_merge::try_merge(
&vault,
&auth,
&branch,
"main",
crate::auto_merge::MergePolicy::AdditiveOnly,
verified,
)
.await
.unwrap_or_else(|e| crate::auto_merge::MergeOutcome {
merged: false,
reason: format!("merge attempt failed: {e}"),
});
eprintln!("library: branch {branch}{}", merge.reason);
Ok(LibraryRun {
harvest: total,
branch,
pushed: true,
merged: merge.merged,
merge_reason: merge.reason,
error: None,
})
}
Err(e) => Ok(LibraryRun {
harvest: total,
branch,
pushed: false,
merged: false,
merge_reason: "not pushed, so not merged".into(),
error: Some(e),
}),
}