Skip to content
Closed
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
17 changes: 7 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,13 @@ cargo nextest run -p <crate> --all-features # or: cargo test -p <crate>
## Finishing

- Add a changelog fragment `changes/<item>.md`.
- If your branch conflicts with `main`, run `git merge origin/main` and push
normally. Never rebase a pushed branch or force-push: the merge queue squashes,
so merge commits on a branch are fine.
- Batch PRs (`graft/batch-<n>`, made by `pr-batch`) are the exception: they
bypass the merge queue, and `land-pr` rebase-merges them with `--admin` once
the required checks pass, so each land's commit survives (D28). Every other
PR squashes through the queue.
- Commit with the identity in your brief, push the branch, and **stop**.
Don't open the PR, don't poll CI. Oversight opens it, reviews it and lands
it.
- Land with `g land` on the graft line (`backlog-1`): commit, `g sync`, then
`g land`. Once landed, **stop**. Don't open a PR, don't poll CI.
- Batch PRs are the normal path. Oversight cuts one batch PR from the line
(`graft/batch-<n>`, made by `pr-batch`) and `land-pr` rebase-merges it with
`--admin` once the required checks pass, so each land's commit survives
(D28). Hand-made PRs squash through the merge queue.
- Commit with the identity in your brief.
- Leave a Vogt hand-off comment on your work item: branch, head sha, and
which checks you ran. Then list **every** Deliver and Accept item from the
work item, each with the file and test that meets it, or `NOT DONE: <why>`.
Expand Down
29 changes: 12 additions & 17 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,19 @@ an issue to discuss a change to the design first.

## How changes land

graft follows trunk-based development, with a merge queue.
graft lands through its own mirror, batched. Workers `g land` on graft line
`backlog-1`. Oversight cuts one batch PR from the line (`graft-mirror
pr-batch`) and merges it with `land-pr --method rebase`, so each land keeps
its own commit (D28). When a backlog of work is under way, all of it goes into
a single batch PR. Batch rules: workspace `.claude/rules/delivery-process.md`
("Landing").

1. **Branch** off `main`: one short-lived branch per work item or change,
named `g<phase>-<n>-<slug>` for plan items. Keep PRs small, and merge them
within hours, not days.
2. **Push** the branch and open a PR against `main`. Agent workers push and
stop. Oversight opens the PR.
3. **Review.** Every PR gets a review, from a different model than the author
where an agent wrote it. Blockers go back to the author.
4. **Merge queue.** A clean review is added to the merge queue, which
**squashes**: each PR lands as one commit on `main`. The queue runs the
required checks on the latest `main`, then merges. Nobody pushes to `main`
directly.
5. **Behind or conflicting with `main`?** Run `git merge origin/main` on your
branch, resolve, and push normally. Never rebase a pushed branch or
force-push. Resolve the root `Cargo.toml` by keeping every dependency line,
sorted. Resolve `Cargo.lock` by taking `main`'s copy and running
`cargo update -w`.
- Graft line: `backlog-1`; mirror: `graft-dev-mirror-graft`
- Required checks: `fmt`, `clippy`, `test` (`.github/workflows/ci.yml`)
- Review: every batch gets a review from a different model than the author
where an agent wrote it. Blockers go back to the author.
- Every change adds a changelog fragment (see [Rules for every change](#rules-for-every-change)).
- Hand-made PRs (rare, operator-agreed) still squash through the merge queue.

**Required checks** (`.github/workflows/ci.yml`, on `pull_request` and
`merge_group`):
Expand Down
1 change: 1 addition & 0 deletions changes/WI-2719.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `graft-mirror pr-batch` gains `--min-lands N`, `--min-age MINUTES` and `--force`, and a `[batch]` table in `mirror.toml` (`min_lands`, `min_age_minutes`, defaults 6 and 60). With thresholds set, a run cuts a batch only when enough lands wait or the previous batch settled long enough ago; otherwise it prints `batch deferred: …` and exits 0. `--force` cuts anyway and marks the PR body `forced`. Unset keeps today's behaviour.
29 changes: 28 additions & 1 deletion crates/graft-mirror/src/batch/batcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ use crate::text::{clean_message, clean_text};
use crate::{ExportLog, MAX_RECORD_EXPORTS, MirrorError, current_ref, move_ref};

use super::render::{
BatchHeader, keep_with_reverts, land_commit, parse_commit, render, settled_keep,
BatchHeader, FORCED_LINE, defer_cut, keep_with_reverts, land_commit, parse_commit, render,
settled_keep,
};
use super::types::{
Attested, BATCH, BRANCH_PREFIX, BatchCommit, BatchConfig, BatchLine, BatchOutcome, BatchRecord,
Expand Down Expand Up @@ -1106,6 +1107,27 @@ where
.max_lands
.unwrap_or(MAX_COMMITS)
.min(MAX_COMMITS);
if !self.config.force {
// The thresholds count from the line's previous batch, which is
// settled here (an open one stops the run before the plan). A
// batch settled before WI-2719 counts from when it was made.
let settled = match (self.config.min_lands, self.config.min_age_ms) {
(None, None) => None,
_ => self
.db
.last_batch(&self.config.line)?
.map(|r| r.settled_ms.unwrap_or(r.created_ms)),
};
if let Some(deferred) = defer_cut(
lands.len(),
self.config.min_lands,
self.config.min_age_ms,
settled,
(self.clock)(),
) {
return Ok(Err(deferred));
}
}
if let Some(age) = self.config.max_age_ms {
let oldest_ms = lands.iter().map(|l| l.authored_ms).min().unwrap_or(0);
let full = lands.len() >= max_lands;
Expand Down Expand Up @@ -1277,6 +1299,7 @@ where
checked: self.config.checked,
fallback: fallback.clone(),
appended: HashSet::new(),
forced: self.config.force,
},
&lands,
);
Expand Down Expand Up @@ -1324,6 +1347,7 @@ where
detail: String::new(),
merge_sha: None,
merged: None,
settled_ms: None,
};
if !self.db.insert_batch(&rec, &commits)? {
return Err(config_err(format!(
Expand Down Expand Up @@ -1826,6 +1850,7 @@ where
checked: self.config.checked,
fallback: None,
appended,
forced: rec.body.contains(FORCED_LINE),
},
&lands,
);
Expand Down Expand Up @@ -2027,6 +2052,7 @@ where
let commits = self.map_merged(rec, merge).await?;
rec.merged = Some(merged);
rec.state = BatchState::Merged;
rec.settled_ms = Some((self.clock)());
rec.detail = format!("merged as {merge} (graft {merged})");
// The merge commit's snapshot is the batch tip's (plus whatever
// GitHub's main gained meanwhile), so the next sync diffs from
Expand Down Expand Up @@ -2132,6 +2158,7 @@ where
"mirror batch rejected; the line is left as it is"
);
rec.state = BatchState::Rejected;
rec.settled_ms = Some((self.clock)());
rec.detail = detail;
self.db.put_batch(rec)?;
Ok(())
Expand Down
42 changes: 40 additions & 2 deletions crates/graft-mirror/src/batch/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ use graft_proto::types::{LandCheck, LandCheckState};
use crate::text::clean_text;

use super::types::{
BATCH_TRAILER, BRANCH_PREFIX, CHANGESET_TRAILER, Dropped, FIELD_CHARS, LandLine, MAX_CHECKS,
MAX_DROPS_LISTED, MAX_LANDS, MAX_PR_BODY, git_time_at, short,
BATCH_TRAILER, BRANCH_PREFIX, BatchOutcome, CHANGESET_TRAILER, Dropped, FIELD_CHARS, LandLine,
MAX_CHECKS, MAX_DROPS_LISTED, MAX_LANDS, MAX_PR_BODY, git_time_at, short,
};

/// What a batch's PR body and commit message say before the land list.
Expand Down Expand Up @@ -42,6 +42,41 @@ pub(crate) struct BatchHeader {
pub(crate) fallback: Option<String>,
/// Lands added after the PR opened.
pub(crate) appended: HashSet<ChangesetId>,
/// Cut with `--force`, past the batch thresholds (WI-2719).
pub(crate) forced: bool,
}

/// The PR body line of a batch cut with `--force`.
pub(crate) const FORCED_LINE: &str =
"- Forced: cut with `--force`, ignoring the batch thresholds (forced)\n";

/// Whether a batch with `lands` waiting is held back by the cut thresholds
/// (WI-2719): `Some(Deferred)` unless `min_lands` lands wait, or the
/// previous batch settled (at `settled_ms`) at least `min_age_ms` before
/// `now_ms`, or there is no previous batch. Both thresholds `None` never
/// defers.
pub(crate) fn defer_cut(
lands: usize,
min_lands: Option<usize>,
min_age_ms: Option<i64>,
settled_ms: Option<i64>,
now_ms: i64,
) -> Option<BatchOutcome> {
if min_lands.is_none() && min_age_ms.is_none() {
return None;
}
let settled_ms = settled_ms?;
if min_lands.is_some_and(|n| lands >= n) {
return None;
}
if min_age_ms.is_some_and(|a| now_ms.saturating_sub(settled_ms) >= a) {
return None;
}
Some(BatchOutcome::Deferred {
lands,
min_lands,
due_ms: min_age_ms.map(|a| settled_ms.saturating_add(a)),
})
}

/// How many of `lands` (newest first) a batch capped at `max` takes, oldest
Expand Down Expand Up @@ -350,6 +385,9 @@ pub(crate) fn render(h: &BatchHeader, lands: &[LandLine]) -> (String, String) {
if let Some(why) = &h.uncut {
head.push_str(&format!("- Not cut: {why}\n"));
}
if h.forced {
head.push_str(FORCED_LINE);
}
if !h.drops.is_empty() {
head.push_str(&format!(
"- Dropped: {} land{}, each reverted on the line (both are in this batch, so \
Expand Down
79 changes: 79 additions & 0 deletions crates/graft-mirror/src/batch/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ fn header(capped: bool) -> BatchHeader {
checked: false,
fallback: Some("test".to_owned()),
appended: std::collections::HashSet::new(),
forced: false,
}
}

Expand Down Expand Up @@ -583,3 +584,81 @@ fn crlf_and_trailing_whitespace_still_join_the_block() {
)
);
}

mod cut_thresholds {
use super::super::render::{FORCED_LINE, defer_cut, render};
use super::super::types::BatchOutcome;
use super::{header, land};

const MIN: i64 = 60_000;
const SETTLED: i64 = 1_000 * MIN;

#[test]
fn below_both_thresholds_defers() {
let out = defer_cut(
2,
Some(6),
Some(60 * MIN),
Some(SETTLED),
SETTLED + 20 * MIN,
);
assert_eq!(
out,
Some(BatchOutcome::Deferred {
lands: 2,
min_lands: Some(6),
due_ms: Some(SETTLED + 60 * MIN),
})
);
}

#[test]
fn enough_lands_cuts() {
assert_eq!(
defer_cut(6, Some(6), Some(60 * MIN), Some(SETTLED), SETTLED + MIN),
None
);
assert_eq!(defer_cut(9, Some(6), None, Some(SETTLED), SETTLED), None);
}

#[test]
fn old_enough_previous_batch_cuts() {
assert_eq!(
defer_cut(
1,
Some(6),
Some(60 * MIN),
Some(SETTLED),
SETTLED + 60 * MIN
),
None
);
assert_eq!(
defer_cut(1, None, Some(60 * MIN), Some(SETTLED), SETTLED + 61 * MIN),
None
);
}

#[test]
fn no_previous_batch_cuts() {
assert_eq!(defer_cut(1, Some(6), Some(60 * MIN), None, 0), None);
}

#[test]
fn unset_thresholds_cut() {
assert_eq!(defer_cut(1, None, None, Some(SETTLED), SETTLED), None);
}

#[test]
fn forced_batch_says_so_in_the_body() {
let mut h = header(false);
h.fallback = None;
let (plain, _) = render(&h, &[land(1, false)]);
assert!(!plain.contains(FORCED_LINE));
h.forced = true;
let (forced, message) = render(&h, &[land(1, false)]);
assert!(forced.contains(FORCED_LINE));
assert!(forced.contains("(forced)"));
assert!(message.contains("forced"));
}
}
26 changes: 26 additions & 0 deletions crates/graft-mirror/src/batch/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ pub struct BatchRecord {
pub merge_sha: Option<GitSha>,
/// The changeset follow imported that merge commit as.
pub merged: Option<ChangesetId>,
/// When a run found the batch merged or closed (ms); `None` while open,
/// and for a batch settled before WI-2719.
pub settled_ms: Option<i64>,
}

/// One per-land commit of a batch (D26).
Expand Down Expand Up @@ -248,6 +251,16 @@ pub struct BatchConfig {
/// checked line batched with this `false` would ship lands whose checks
/// have not run. An unimplemented RPC is a warning, not a refusal.
pub checked: bool,
/// Cut thresholds (WI-2719): with either set, a batch is cut only when
/// at least `min_lands` lands wait, or the previous batch settled at
/// least `min_age_ms` ago, or the line has no previous batch. Both
/// `None` cuts whenever there is something to batch.
pub min_lands: Option<usize>,
/// See [`min_lands`](Self::min_lands) (ms).
pub min_age_ms: Option<i64>,
/// Cut regardless of `min_lands` and `min_age_ms` (a release blocker);
/// the PR body says so.
pub force: bool,
}

impl BatchConfig {
Expand All @@ -267,6 +280,9 @@ impl BatchConfig {
max_lands: None,
max_age_ms: None,
checked: false,
min_lands: None,
min_age_ms: None,
force: false,
}
}
}
Expand Down Expand Up @@ -299,6 +315,16 @@ pub enum BatchOutcome {
/// The oldest one's author time (ms).
oldest_ms: i64,
},
/// Fewer than `min_lands` lands wait and the previous batch settled
/// less than `min_age_ms` ago; no batch yet (WI-2719).
Deferred {
/// Lands waiting.
lands: usize,
/// The land count that makes a batch due, if set.
min_lands: Option<usize>,
/// When the age threshold makes a batch due (ms), if set.
due_ms: Option<i64>,
},
/// The line is checked and its oldest land's post-land checks are not
/// settled (pending, or bad with no revert yet); no batch yet.
Unsettled {
Expand Down
Loading
Loading