1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
//! Patch-kind regression coverage for `ngit send --in-reply-to <existing-root>`
//! producing a **proposal revision** — successor to the legacy keepers under
//! `tests/legacy/ngit_send.
//! rs::root_proposal_specified_using_in_reply_to_with_range_of_head_2_and_cover_letter_details_specified`
//! (`cover_letter_tags::{t_tag_root, t_tag_revision_root,
//! e_tag_in_reply_to_event_as_reply}` and
//! `patch_tags_cover_letter_event_as_root`).
//!
//! ## Shape
//!
//! - 1 vanilla relay (`"default"`) + 1 GRASP (`"repo"`); single maintainer
//! (none of these assertions touch per-maintainer `a` / `p` tags).
//! - Maintainer publishes the repo via [`Harness::publish_repo`].
//! - **Original proposal**: a first contributor publishes a cover-lettered
//! patch series (`a3.md`, `a4.md` on `feature-1`).
//! - **Revision**: a second contributor publishes another cover-lettered patch
//! series (`b3.md`, `b4.md` on `feature-2`) with `--in-reply-to
//! <original_cover_letter_id>`. This is the event we assert against.
//!
//! Two different contributor identities is incidental (each
//! [`Harness::publish_patch_series`] call mints a fresh contributor) and
//! does not affect the assertions: ngit resolves `--in-reply-to` by event id,
//! not by author.
//!
//! ## rstest discipline
//!
//! Each `#[case]` is a read-only assertion on the captured revision events.
//! No case mutates the repo or the relays; the fixture rebuilds the harness
//! per case.
//!
//! ## Dropped from legacy
//!
//! `cli_ouput::check_cli_output` — banned by harness rules
//! (`docs/architecture/test-harness.md` § "Anti-patterns"). The
//! revision-is-published contract is asserted by every other case in this
//! file.
use anyhow::{Context, Result, anyhow};
use nostr_sdk::prelude::*;
use rstest::*;
use test_harness::{Harness, PublishPatchSeriesOpts, PublishRepoOpts};
const ORIGINAL_TITLE: &str = "original proposal";
const ORIGINAL_DESCRIPTION: &str = "the proposal being revised";
const REVISION_TITLE: &str = "revised proposal";
const REVISION_DESCRIPTION: &str = "the revision";
/// Captured side-effects of one revision-publish (an `ngit send
/// --force-patch --title ... --description ... --in-reply-to <original>`
/// against an existing patch-series proposal).
struct Snapshot {
/// Cover letter of the **revision** (the event under test). Its
/// tags carry both the new-proposal markers (`t root`) and the
/// revision-specific markers (`t revision-root`, `e <reply>` →
/// original).
revision_cover_letter: Event,
/// One of the per-commit patches in the revision series (we pick
/// the first by commit oid). Used to assert that per-commit patches
/// thread off the revision's own cover letter via `e <root>`.
revision_patch: Event,
/// Cover letter id of the **original** proposal — the value the
/// revision's `e <reply>` tag should point at.
original_cover_letter_id: EventId,
}
#[fixture]
async fn snapshot() -> Snapshot {
capture_snapshot()
.await
.expect("send_patch_revision fixture: capture_snapshot failed")
}
async fn capture_snapshot() -> Result<Snapshot> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_grasp_server("repo")
.build()
.await?;
let (publisher, published) = harness.publish_repo(PublishRepoOpts::default()).await?;
// --- original proposal -------------------------------------------------
//
// Cover-lettered so the resulting `cover_letter_event` (kind 1617 with
// `t cover-letter`) can serve as the `--in-reply-to` anchor for the
// revision. Branch + commit names are arbitrary; we pick `feature-1` /
// `a*.md` to leave `feature-2` / `b*.md` available for the revision
// without name collisions in the contributor's clone.
let original = harness
.publish_patch_series(
&published,
PublishPatchSeriesOpts {
branch: Some("feature-1".into()),
commits: vec![
("a3.md".into(), "some content\n".into()),
("a4.md".into(), "some content\n".into()),
],
cover_letter: Some((ORIGINAL_TITLE.into(), ORIGINAL_DESCRIPTION.into())),
..Default::default()
},
)
.await?;
let original_cover_letter = original
.cover_letter_event
.clone()
.context("original proposal did not produce a cover-letter event")?;
// This event-shape fixture deliberately publishes the revision through a
// second fresh clone. Opt into the legacy fetch-all mode so that generic
// scenario helper has the original proposal tip needed to validate the
// revision. Checkout behavior itself is covered in pr_checkout_patch.rs.
publisher
.git_ok(
["config", "--global", "nostr.auto-pr-branches", "true"],
"enable automatic PR branches for the revision fixture",
)
.await?;
// --- revision ----------------------------------------------------------
//
// `--in-reply-to <original_cover_letter_id_hex>` marks the new series
// as a revision of the original. The revision is signed by a fresh
// contributor — `publish_patch_series` mints one per call. ngit
// resolves `--in-reply-to` by event id and does not check author
// identity, so this is fine.
let revision = harness
.publish_patch_series(
&published,
PublishPatchSeriesOpts {
branch: Some("feature-2".into()),
commits: vec![
("b3.md".into(), "some content\n".into()),
("b4.md".into(), "some content\n".into()),
],
cover_letter: Some((REVISION_TITLE.into(), REVISION_DESCRIPTION.into())),
in_reply_to: vec![original_cover_letter.id.to_hex()],
},
)
.await?;
let revision_cover_letter = revision
.cover_letter_event
.clone()
.context("revision did not produce a cover-letter event")?;
let first_commit_oid = revision
.commits
.first()
.cloned()
.context("revision returned no commits")?;
let revision_patch = find_patch_for_commit(&revision.patch_events, &first_commit_oid)?;
Ok(Snapshot {
revision_cover_letter,
revision_patch,
original_cover_letter_id: original_cover_letter.id,
})
}
fn find_patch_for_commit(patches: &[Event], oid: &str) -> Result<Event> {
patches
.iter()
.find(|e| tag_value(e, "commit").as_deref() == Some(oid))
.cloned()
.ok_or_else(|| anyhow!("no patch event found with commit tag {oid}"))
}
fn tag_value(event: &Event, key: &str) -> Option<String> {
event.tags.iter().find_map(|t| {
let s = t.as_slice();
if s.first().map(String::as_str) == Some(key) {
s.get(1).cloned()
} else {
None
}
})
}
fn tag_values(event: &Event, key: &str) -> Vec<String> {
event
.tags
.iter()
.filter_map(|t| {
let s = t.as_slice();
if s.first().map(String::as_str) == Some(key) {
s.get(1).cloned()
} else {
None
}
})
.collect()
}
#[derive(Debug, Clone, Copy)]
enum Case {
/// Revision cover letter still carries `["t", "root"]` — a revision
/// *is* a new proposal root from the threading perspective, even
/// though it also carries a back-reference to the original via
/// `revision-root`.
TTagRoot,
/// Revision cover letter additionally carries `["t", "revision-root"]`
/// (or the legacy alias `"root-revision"`). This is the marker
/// downstream clients use to distinguish revisions from first-time
/// proposals.
TTagRevisionRoot,
/// Revision cover letter carries a 4-slot
/// `["e", <original_cover_letter_id>, _, "reply"]` tag pointing at
/// the original proposal's root. This is how a revision references
/// what it revises.
ETagReplyToOriginal,
/// Per-commit patches in the revision thread off the **revision's
/// own** cover letter via a 4-slot `["e", <cover_letter_id>, _,
/// "root"]` tag — same shape as the per-commit-patch chaining in a
/// non-revision cover-lettered series.
PatchETagRootIsRevisionCoverLetter,
}
#[rstest]
#[case::t_tag_root(Case::TTagRoot)]
#[case::t_tag_revision_root(Case::TTagRevisionRoot)]
#[case::e_tag_reply_to_original(Case::ETagReplyToOriginal)]
#[case::patch_e_tag_root_is_revision_cover_letter(Case::PatchETagRootIsRevisionCoverLetter)]
#[tokio::test]
async fn revision_tags(#[future] snapshot: Snapshot, #[case] case: Case) -> Result<()> {
let s = snapshot.await;
match case {
Case::TTagRoot => {
assert!(
tag_values(&s.revision_cover_letter, "t")
.iter()
.any(|v| v == "root"),
"revision cover letter should carry `t root`; tags were {:?}",
s.revision_cover_letter.tags,
);
}
Case::TTagRevisionRoot => {
// Accept either spelling — `src/lib/git_events.rs` has
// historically emitted both, and the legacy assertion was
// also tolerant.
let t_values = tag_values(&s.revision_cover_letter, "t");
assert!(
t_values
.iter()
.any(|v| v == "revision-root" || v == "root-revision"),
"revision cover letter should carry `t revision-root` (or the alias \
`root-revision`); t tags were {t_values:?}",
);
}
Case::ETagReplyToOriginal => {
let reply_e = s
.revision_cover_letter
.tags
.iter()
.find(|t| {
let v = t.as_slice();
v.first().map(String::as_str) == Some("e")
&& v.len() == 4
&& v.get(3).map(String::as_str) == Some("reply")
})
.ok_or_else(|| {
anyhow!(
"revision cover letter missing reply-marker `e` tag (tags: {:?})",
s.revision_cover_letter.tags
)
})?;
assert_eq!(
reply_e.as_slice().get(1).map(String::as_str),
Some(s.original_cover_letter_id.to_hex().as_str()),
"revision cover letter's `e <reply>` should point at the original proposal's root",
);
}
Case::PatchETagRootIsRevisionCoverLetter => {
let root_e = s
.revision_patch
.tags
.iter()
.find(|t| {
let v = t.as_slice();
v.first().map(String::as_str) == Some("e")
&& v.len() == 4
&& v.get(3).map(String::as_str) == Some("root")
})
.ok_or_else(|| {
anyhow!(
"revision per-commit patch missing root-marker `e` tag (tags: {:?})",
s.revision_patch.tags
)
})?;
assert_eq!(
root_e.as_slice().get(1).map(String::as_str),
Some(s.revision_cover_letter.id.to_hex().as_str()),
"revision per-commit patch's `e <root>` should point at the revision's own \
cover letter (not the original proposal's)",
);
}
}
Ok(())
}