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
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
//! Lighthouse test: when the first git server in a repo's announcement is
//! unreachable, `git clone <nostr://...>` falls through to the next one.
//!
//! Companion to `tests/clone_grasp.rs` (single-server happy path) and
//! `tests/fetch_grasp.rs` (post-clone fetch). Together they cover the
//! single-server, multi-server-fallback, and incremental-fetch surfaces of
//! the read path.
//!
//! Flow:
//!
//! 1. Harness spins up one vanilla relay (`default`) and two grasp servers
//! (`primary`, `secondary`).
//! 2. Publisher: `ngit account create`, commit, `ngit init --grasp-server
//! <primary> --grasp-server <secondary>`, then `git push -u origin main`.
//! `ngit push` to a nostr remote fans out to every git server listed in the
//! repo's announcement (`src/lib/push.rs:472`), so both grasps wind up with
//! the pack data and the announcement.
//! 3. Test calls `Harness::take_grasp("primary")` and drops the returned
//! server. The subprocess is killed and its loopback port becomes unbindable
//! until kernel cleanup — connection attempts get `ECONNREFUSED`.
//! 4. Cloner: fresh repo, `git clone <nostr-url>`. `git-remote-nostr` iterates
//! `repo_ref.git_server` in announcement order (see
//! `src/bin/git_remote_nostr/fetch.rs:43`); the primary fails, the secondary
//! succeeds, the clone completes.
//! 5. Assert the clone's working tree contains the publisher's content and
//! `refs/remotes/origin/main` matches the publisher's tip.
//!
//! ## Why we don't reach the announcement via the dead primary's relay
//!
//! The vanilla `default` relay also receives the kind-30617 fan-out
//! (`Params::default()` lists both grasp-derived relays *and* the user's
//! `relay_default_set`). It's the only relay surface guaranteed alive
//! after the kill, and ngit's relay discovery walks both the URL's
//! embedded relay and `NGIT_RELAY_DEFAULT_SET`. The dead primary's relay
//! drops out of the rotation the same way its git endpoint does.
use anyhow::{Context, Result};
use nostr_sdk::prelude::*;
use test_harness::Harness;
#[tokio::test]
async fn clone_falls_through_when_first_grasp_is_unreachable() -> Result<()> {
let mut harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_grasp_server("primary")
.with_grasp_server("secondary")
.build()
.await?;
let publisher = harness.fresh_repo()?;
let display_name = "lighthouse fetch failover";
let identifier = "lighthouse-fetch-failover";
// --- step 1: account create ---------------------------------------------
let create_output = publisher
.ngit(["account", "create", "--local", "--name", display_name])
.output()
.await
.context("failed to spawn ngit account create")?;
assert!(
create_output.status.success(),
"ngit account create exited non-zero ({:?})\nstdout: {}\nstderr: {}",
create_output.status,
String::from_utf8_lossy(&create_output.stdout),
String::from_utf8_lossy(&create_output.stderr),
);
let nsec = publisher
.config("nostr.nsec")
.await?
.context("nostr.nsec missing from local git config after account create")?;
let keys = Keys::parse(&nsec).context("nostr.nsec from local config is not a valid key")?;
let pubkey = keys.public_key();
// --- step 2: commit + init with TWO grasp servers -----------------------
std::fs::write(publisher.dir().join("README.md"), "failover test\n")
.context("failed to write README.md")?;
run_git_ok(&publisher, ["add", "README.md"], "git add").await?;
run_git_ok(
&publisher,
["commit", "-m", "initial", "--no-gpg-sign"],
"git commit",
)
.await?;
let main_oid = publisher
.snapshot()?
.refs
.get("refs/heads/main")
.context("refs/heads/main missing after initial commit")?
.clone();
let primary_url = harness.grasp("primary").url().to_string();
let secondary_url = harness.grasp("secondary").url().to_string();
// `--grasp-server` is `clap`-declared as `num_args = 1..` (see
// src/bin/ngit/sub_commands/init.rs:412), so multiple values go on one
// flag instance. The declaration order here is also the order that
// ends up in `repo_ref.git_server` and therefore the order
// git-remote-nostr tries on fetch — primary first, secondary as
// fallback.
let init_output = publisher
.ngit([
"init",
"--name",
display_name,
"--identifier",
identifier,
"--grasp-server",
&primary_url,
&secondary_url,
"-d",
])
.output()
.await
.context("failed to spawn ngit init")?;
assert!(
init_output.status.success(),
"ngit init exited non-zero ({:?})\nstdout: {}\nstderr: {}",
init_output.status,
String::from_utf8_lossy(&init_output.stdout),
String::from_utf8_lossy(&init_output.stderr),
);
let init_stdout = String::from_utf8_lossy(&init_output.stdout);
let printed_clone_url = extract_clone_url(&init_stdout).with_context(|| {
format!("no `clone url:` line in ngit init stdout. full stdout was:\n{init_stdout}")
})?;
// The URL printed by ngit init embeds `coordinate.relays.first()` —
// currently the **primary** grasp's relay (see
// `src/lib/repo_ref.rs::coordinate_with_hint` + `Display for
// NostrUrlDecoded`). After we kill the primary that relay disappears
// along with the git endpoint, so the cloner would not even be able to
// fetch the announcement.
//
// The legacy `when_first_git_server_fails_` scenario sidestepped this
// by keeping relays and git servers as separate entities. The harness
// collapses them into one process per grasp, so to recover the same
// "dead git endpoint, live relay" property we drop the relay hint from
// the URL and let the cloner consult `NGIT_RELAY_DEFAULT_SET` (the
// vanilla relay, where init's fan-out also placed a copy of the
// announcement). The announcement's own `relays` tag — listing both
// grasp relays plus the user's relay-list — and its `clone` tags
// (primary then secondary) are unaffected, so once it's read the
// server-fallback logic exercises exactly the legacy path.
let npub = pubkey
.to_bech32()
.context("failed to bech32-encode publisher pubkey")?;
let clone_url = format!("nostr://{npub}/{identifier}");
assert!(
printed_clone_url.contains(&npub),
"sanity: printed clone URL {printed_clone_url} should reference publisher npub",
);
// --- step 3: push, fanning out to both grasps ----------------------------
run_git_ok(
&publisher,
["push", "-u", "origin", "main"],
"git push origin main",
)
.await?;
// Sanity: both grasps received the announcement *and* the state event.
// Without this, the failover assertion is meaningless — we'd really
// be testing "single-server clone via secondary" rather than
// "fallback past dead primary".
for role in ["primary", "secondary"] {
let grasp = harness.grasp(role);
let announcements = grasp
.events(Filter::new().author(pubkey).kind(Kind::GitRepoAnnouncement))
.await?;
assert_eq!(
announcements.len(),
1,
"grasp {role:?} should have exactly one announcement after push; got {}",
announcements.len(),
);
let state_events = grasp
.events(Filter::new().author(pubkey).kind(Kind::Custom(30618)))
.await?;
assert!(
!state_events.is_empty(),
"grasp {role:?} should have at least one state event after push",
);
}
// The vanilla `default` relay also carries the announcement — it's the
// discovery surface that survives killing the primary grasp. If this
// assertion ever fails, the test below will report "no announcement
// event found" and the failure mode is "relay fan-out did not include
// the user's default-set" rather than "fallback git logic broken".
let default_anns = harness
.relay("default")
.events(Filter::new().author(pubkey).kind(Kind::GitRepoAnnouncement))
.await?;
assert_eq!(
default_anns.len(),
1,
"vanilla default relay should carry the announcement; got {}",
default_anns.len(),
);
// --- step 4: take + drop the primary grasp ------------------------------
//
// `take_grasp` returns the owned `GraspServer`; dropping it signals
// the subprocess and waits for it to exit (`grasp.rs::Drop`). After
// this, `127.0.0.1:<primary_port>` no longer accepts connections.
let primary = harness
.take_grasp("primary")
.context("primary grasp was already taken or never registered")?;
let dead_url = primary.url().to_string();
drop(primary);
// Sanity check that the primary really is down — otherwise a passing
// test could just be hiding a no-op.
let probe = tokio::net::TcpStream::connect(dead_url.trim_start_matches("http://")).await;
assert!(
probe.is_err(),
"primary grasp should be down after drop, but TCP connect to {dead_url} succeeded",
);
// --- step 5: cloner clones — must succeed via secondary -----------------
//
// `fresh_repo` runs *after* the take_grasp call, so the cloner's env
// (`NGIT_GRASP_DEFAULT_SET`) only references the secondary. That's
// immaterial for clone resolution — git-remote-nostr reads the git
// server list from the announcement, not from env vars — but it
// keeps the harness state consistent for any future operation.
let cloner = harness.fresh_repo()?;
let clone_dir_name = "cloned";
let clone_target = cloner.dir().join(clone_dir_name);
run_git_ok(
&cloner,
["clone", &clone_url, clone_dir_name],
"git clone over nostr:// with dead primary",
)
.await?;
assert!(
clone_target.join(".git").is_dir(),
"git clone reported success but .git is missing at {}",
clone_target.display(),
);
let cloned_readme = clone_target.join("README.md");
let cloned_contents = std::fs::read_to_string(&cloned_readme).with_context(|| {
format!(
"README.md missing from clone at {} — clone returned success but checkout produced no working tree",
cloned_readme.display()
)
})?;
assert_eq!(
cloned_contents, "failover test\n",
"cloned README.md content does not match publisher's",
);
let cloned_oid = {
let repo = git2::Repository::open(&clone_target)
.with_context(|| format!("open clone at {}", clone_target.display()))?;
let reference = repo
.find_reference("refs/remotes/origin/main")
.context("find_reference refs/remotes/origin/main")?;
reference
.target()
.context("refs/remotes/origin/main has no direct target")?
.to_string()
};
assert_eq!(
cloned_oid, main_oid,
"clone's origin/main ({cloned_oid}) does not match publisher's ({main_oid})",
);
Ok(())
}
/// Run a `git` subprocess inside `repo`, asserting it exited successfully.
async fn run_git_ok<I, S>(repo: &test_harness::Repo, args: I, label: &str) -> Result<()>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let out = repo
.git(args)
.output()
.await
.with_context(|| format!("failed to spawn {label}"))?;
if !out.status.success() {
anyhow::bail!(
"{label} exited non-zero ({:?})\nstdout: {}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
Ok(())
}
/// Pull the first `nostr://...` URL printed after a `clone url:` /
/// `your clone URL:` label in `ngit init` stdout. Same shape as
/// `tests/clone_grasp.rs::extract_clone_url`.
fn extract_clone_url(stdout: &str) -> Option<String> {
for line in stdout.lines() {
let lower = line.to_ascii_lowercase();
if let Some(idx) = lower.find("clone url:") {
let rest = line[idx + "clone url:".len()..].trim();
if rest.starts_with("nostr://") {
return Some(rest.to_string());
}
}
}
None
}