Skip to content

Commit 3e20ad4

Browse files
jdclaude
andauthored
feat(rust): port stack note to native Rust (#1515)
The Rust binary now serves ``mergify stack note [<commit>] [-m <msg>] [--append] [--remove]`` natively. The Python implementation (``mergify_cli/stack/note.py``, its click registration in ``mergify_cli/stack/cli.py``, and the unit suite in ``mergify_cli/tests/stack/test_note.py``) is removed in the same PR. Second ``stack`` subcommand to land natively (after ``stack new`` in the preceding commit). The hybrid dispatcher in ``crates/mergify-cli/src/main.rs::dispatch_stack`` grows a single ``"note"`` arm; everything else still flows through the Python shim. ``mergify stack note [<COMMIT>]`` resolves the target commit by: 1. ``None`` → ``HEAD``. 2. Argument matches ``I[0-9a-f]+`` — walk the stack (``<merge-base trunk HEAD>..HEAD``) and match by ``Change-Id``. Reuses ``mergify_stack::trunk::get_trunk`` (added with ``stack new``) and ``mergify_stack::local_commits::read``. 3. Otherwise — ``git rev-parse --verify <commit>^{commit}``. Then dispatches based on the action: - ``--remove`` — no-op when the commit has no note (prints ``No note on <sha> <subject>.`` and exits 0); otherwise ``git notes --ref=refs/notes/mergify/stack remove``. - ``-m MSG`` (default) — ``git notes … add -f -m MSG``. - ``-m MSG --append`` — ``git notes … append -m MSG``. - no ``-m`` — open ``$GIT_EDITOR`` / ``$VISUAL`` / ``$EDITOR`` / ``vi`` on a tempfile with the comment-line template, strip comment lines, treat empty result as ``InvalidState``. ``NOTES_REF`` previously lived on the deleted ``mergify_cli/stack/note.py``. The sole remaining Python consumer (``stack/push.py``, which pushes the notes ref alongside the stack branches) carries its own constant now. Exit-code mapping mirrors Python: ``InvalidState`` for empty notes and unresolved Change-Id prefixes; ``GenericError`` for git invocation failures. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 029c388 commit 3e20ad4

10 files changed

Lines changed: 790 additions & 484 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/mergify-cli/src/main.rs

Lines changed: 125 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ const NATIVE_COMMANDS: &[(&str, &str)] = &[
157157
("freeze", "update"),
158158
("freeze", "delete"),
159159
("stack", "new"),
160+
("stack", "note"),
160161
// Internal Python migration helpers. Listed so `looks_native`
161162
// routes `mergify _internal …` past the shim fallback when
162163
// clap rejects it, but they stay hidden from `--help` (see
@@ -215,6 +216,25 @@ enum NativeCommand {
215216
/// tracking the resolved trunk. First stack subcommand to land
216217
/// natively; the rest still shim to Python.
217218
StackNew(StackNewOpts),
219+
/// `mergify stack note [<commit>] [-m <msg>] [--append]
220+
/// [--remove]` — attach/append/remove the "why was this commit
221+
/// amended" note on `refs/notes/mergify/stack`.
222+
StackNote(StackNoteOpts),
223+
}
224+
225+
struct StackNoteOpts {
226+
/// Commit to target — `None` means HEAD. Accepts a SHA prefix,
227+
/// a ref (`HEAD~1`, branch name, etc.), or a Change-Id prefix
228+
/// (resolved against the stack walk).
229+
commit: Option<String>,
230+
/// Inline message; `None` means "open `$GIT_EDITOR`". Mutually
231+
/// exclusive with `remove`.
232+
message: Option<String>,
233+
/// Concatenate to the existing note instead of replacing.
234+
append: bool,
235+
/// Remove the note. Mutually exclusive with `message` /
236+
/// `append`.
237+
remove: bool,
218238
}
219239

220240
struct StackNewOpts {
@@ -481,17 +501,26 @@ fn detect_dispatch(argv: &[String]) -> Option<Dispatch> {
481501
/// subcommands later means adding a branch here and a matching
482502
/// `NATIVE_COMMANDS` entry.
483503
fn dispatch_stack(debug: bool, args: Vec<String>) -> Dispatch {
484-
if args.first().is_some_and(|a| a == "new") {
485-
// `args[0]` is `"new"` — clap consumes it as the program
486-
// name in the secondary parse, leaving `args[1..]` as the
487-
// actual arguments.
488-
let parsed = match StackNewCli::try_parse_from(&args) {
489-
Ok(parsed) => parsed,
490-
Err(err) => err.exit(),
491-
};
492-
return Dispatch::Native(NativeCommand::StackNew(StackNewOpts::from(parsed)));
504+
match args.first().map(String::as_str) {
505+
Some("new") => {
506+
// `args[0]` is the subcommand — clap consumes it as
507+
// the program name in the secondary parse, leaving
508+
// `args[1..]` as the actual arguments.
509+
let parsed = match StackNewCli::try_parse_from(&args) {
510+
Ok(p) => p,
511+
Err(err) => err.exit(),
512+
};
513+
Dispatch::Native(NativeCommand::StackNew(StackNewOpts::from(parsed)))
514+
}
515+
Some("note") => {
516+
let parsed = match StackNoteCli::try_parse_from(&args) {
517+
Ok(p) => p,
518+
Err(err) => err.exit(),
519+
};
520+
Dispatch::Native(NativeCommand::StackNote(StackNoteOpts::from(parsed)))
521+
}
522+
_ => Dispatch::Shim(inject_global_flags(debug, prepend_one("stack", args))),
493523
}
494-
Dispatch::Shim(inject_global_flags(debug, prepend_one("stack", args)))
495524
}
496525

497526
#[allow(clippy::too_many_lines)] // mostly mechanical match arms
@@ -1169,6 +1198,48 @@ fn run_native(cmd: NativeCommand) -> ExitCode {
11691198
}
11701199
Ok(mergify_core::ExitCode::Success)
11711200
}
1201+
NativeCommand::StackNote(opts) => {
1202+
let action = if opts.remove {
1203+
mergify_stack::commands::note::Action::Remove
1204+
} else if let Some(msg) = opts.message {
1205+
if opts.append {
1206+
mergify_stack::commands::note::Action::Append(msg)
1207+
} else {
1208+
mergify_stack::commands::note::Action::Set(msg)
1209+
}
1210+
} else {
1211+
mergify_stack::commands::note::Action::FromEditor
1212+
};
1213+
let outcome = mergify_stack::commands::note::run(
1214+
None,
1215+
opts.commit.as_deref(),
1216+
action,
1217+
)?;
1218+
match outcome {
1219+
mergify_stack::commands::note::Outcome::Attached { sha, subject } => {
1220+
println!(
1221+
"Note attached to {short} {subject}.",
1222+
short = &sha[..sha.len().min(12)],
1223+
);
1224+
}
1225+
mergify_stack::commands::note::Outcome::Removed { sha, subject } => {
1226+
println!(
1227+
"Note removed from {short} {subject}.",
1228+
short = &sha[..sha.len().min(12)],
1229+
);
1230+
}
1231+
mergify_stack::commands::note::Outcome::NoNoteToRemove {
1232+
sha,
1233+
subject,
1234+
} => {
1235+
println!(
1236+
"No note on {short} {subject}.",
1237+
short = &sha[..sha.len().min(12)],
1238+
);
1239+
}
1240+
}
1241+
Ok(mergify_core::ExitCode::Success)
1242+
}
11721243
NativeCommand::InternalStackLocalCommits(opts) => {
11731244
// Run `git log` for the stack range, parse each
11741245
// commit's `Change-Id:` trailer, emit a JSON array
@@ -1361,6 +1432,50 @@ impl From<StackNewCli> for StackNewOpts {
13611432
}
13621433
}
13631434

1435+
/// `mergify stack note [<commit>]` — clap definition for the
1436+
/// natively-ported `stack note` subcommand. Same secondary-parse
1437+
/// pattern as `stack new`.
1438+
#[derive(Parser)]
1439+
#[command(
1440+
name = "note",
1441+
about = "Attach a 'why was this commit amended' note to a commit"
1442+
)]
1443+
struct StackNoteCli {
1444+
/// Target commit. Accepts a SHA prefix, a ref (`HEAD~1`,
1445+
/// branch name, …), or a Change-Id prefix (resolved against
1446+
/// the stack walk). Defaults to HEAD.
1447+
commit: Option<String>,
1448+
1449+
/// Note message. If omitted, opens `$GIT_EDITOR` /
1450+
/// `$VISUAL` / `$EDITOR` / `vi` on a tempfile.
1451+
#[arg(short = 'm', long = "message")]
1452+
message: Option<String>,
1453+
1454+
/// Append to an existing note instead of replacing.
1455+
#[arg(long = "append", action = clap::ArgAction::SetTrue)]
1456+
append: bool,
1457+
1458+
/// Remove the note on the target commit. Mutually exclusive
1459+
/// with `--message` and `--append`.
1460+
#[arg(
1461+
long = "remove",
1462+
action = clap::ArgAction::SetTrue,
1463+
conflicts_with_all = ["message", "append"],
1464+
)]
1465+
remove: bool,
1466+
}
1467+
1468+
impl From<StackNoteCli> for StackNoteOpts {
1469+
fn from(cli: StackNoteCli) -> Self {
1470+
Self {
1471+
commit: cli.commit,
1472+
message: cli.message,
1473+
append: cli.append,
1474+
remove: cli.remove,
1475+
}
1476+
}
1477+
}
1478+
13641479
/// Parse a `REMOTE/BRANCH` argument into its two parts. Matches
13651480
/// the Python `trunk_type` click callback in
13661481
/// `mergify_cli/stack/cli.py`: split on the first `/`, so branch

crates/mergify-stack/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ mergify-core = { path = "../mergify-core" }
1414
regex = "1"
1515
serde = { version = "1.0", features = ["derive"] }
1616
serde_json = "1.0"
17+
tempfile = "3.14"
1718
url = "2"
1819

1920
[dev-dependencies]
20-
tempfile = "3.14"
21+
temp-env = "0.3"
2122
tokio = { version = "1", default-features = false, features = ["macros", "rt"] }
2223
url = "2"
2324
wiremock = "0.6"

crates/mergify-stack/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
//! rest still shim to Python.
55
66
pub mod new;
7+
pub mod note;

0 commit comments

Comments
 (0)