upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-17 12:11:33 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-17 14:04:32 +0000
commit5cc8ae28cb462d2cb0b7f74dfa5238e62d17eb70 (patch)
tree7e1f81c8204c3f50b30fa69b410f60ffa872bec7
parent06ebbf7c73b64225ae083bb3134f7c7c630c5565 (diff)
feat: support multiline descriptions in push-options via \n escape
Git push-options are line-based so literal newlines cannot be sent. Users can now write the two-character sequence \n which is decoded into real newlines before publishing. Use \\n for a literal backslash-n. Includes unit tests, integration test, help text, and changelog entry.
-rw-r--r--CHANGELOG.md1
-rw-r--r--src/bin/git_remote_nostr/main.rs113
-rw-r--r--src/bin/ngit/cli.rs2
-rw-r--r--tests/git_remote_nostr/push.rs108
4 files changed, 220 insertions, 4 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c2b07c..af4c833 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
26- `ngit list --status` - filter by status (open,draft,closed,applied) 26- `ngit list --status` - filter by status (open,draft,closed,applied)
27- `ngit init --hashtag` - specify repository hashtag 27- `ngit init --hashtag` - specify repository hashtag
28- Push options for PR title/description: `git push --push-option=title="..." --push-option=description="..."` 28- Push options for PR title/description: `git push --push-option=title="..." --push-option=description="..."`
29 - Multiline support: use `\n` for newlines in values (e.g. `--push-option=description='line1\n\nline2'`). Use single quotes to prevent shell interpretation. Use `\\n` for a literal backslash-n.
29 30
30### Removed 31### Removed
31 32
diff --git a/src/bin/git_remote_nostr/main.rs b/src/bin/git_remote_nostr/main.rs
index 61c439d..f5b88ac 100644
--- a/src/bin/git_remote_nostr/main.rs
+++ b/src/bin/git_remote_nostr/main.rs
@@ -30,6 +30,43 @@ struct PushOptions {
30 description: Option<String>, 30 description: Option<String>,
31} 31}
32 32
33/// Decode escape sequences in push-option values.
34///
35/// Git push-options are transmitted one per line, so literal newlines
36/// cannot appear in a value. To support multiline titles and
37/// descriptions users can write the two-character sequence `\n` which
38/// this function converts to a real newline. A literal backslash
39/// before `n` can be preserved by doubling it (`\\n`).
40///
41/// # Examples
42/// ```text
43/// "first line\\nsecond line" -> "first line\nsecond line"
44/// "keep \\\\n literal" -> "keep \\n literal"
45/// "no escapes here" -> "no escapes here"
46/// ```
47fn decode_push_option_escapes(s: &str) -> String {
48 let mut result = String::with_capacity(s.len());
49 let mut chars = s.chars().peekable();
50 while let Some(c) = chars.next() {
51 if c == '\\' {
52 match chars.peek() {
53 Some('n') => {
54 chars.next();
55 result.push('\n');
56 }
57 Some('\\') => {
58 chars.next();
59 result.push('\\');
60 }
61 _ => result.push(c),
62 }
63 } else {
64 result.push(c);
65 }
66 }
67 result
68}
69
33impl PushOptions { 70impl PushOptions {
34 fn validate(&self) -> Result<Option<(String, String)>> { 71 fn validate(&self) -> Result<Option<(String, String)>> {
35 match (&self.title, &self.description) { 72 match (&self.title, &self.description) {
@@ -111,8 +148,12 @@ async fn main() -> Result<()> {
111 let option = rest.join(" "); 148 let option = rest.join(" ");
112 if let Some((key, value)) = option.split_once('=') { 149 if let Some((key, value)) = option.split_once('=') {
113 match key { 150 match key {
114 "title" => push_options.title = Some(value.to_string()), 151 "title" => {
115 "description" => push_options.description = Some(value.to_string()), 152 push_options.title = Some(decode_push_option_escapes(value));
153 }
154 "description" => {
155 push_options.description = Some(decode_push_option_escapes(value));
156 }
116 _ => {} 157 _ => {}
117 } 158 }
118 } 159 }
@@ -168,7 +209,7 @@ async fn process_args() -> Result<Option<(NostrUrlDecoded, Repo)>> {
168 println!("nostr plugin for git"); 209 println!("nostr plugin for git");
169 println!("Usage:"); 210 println!("Usage:");
170 println!( 211 println!(
171 " - clone a nostr repository, or add as a remote, by using the url format nostr://pub123/identifier" 212 " - clone a nostr repository, or add as a remote, by using the url format nostr://npub123/identifier"
172 ); 213 );
173 println!( 214 println!(
174 " - remote branches beginning with `pr/` are open PRs from contributors; `ngit list` can be used to view all PRs" 215 " - remote branches beginning with `pr/` are open PRs from contributors; `ngit list` can be used to view all PRs"
@@ -176,6 +217,12 @@ async fn process_args() -> Result<Option<(NostrUrlDecoded, Repo)>> {
176 println!( 217 println!(
177 " - to open a PR, push a branch with the prefix `pr/` or use `ngit send` for advanced options" 218 " - to open a PR, push a branch with the prefix `pr/` or use `ngit send` for advanced options"
178 ); 219 );
220 println!(" - set PR title/description via push options:");
221 println!(" git push -o 'title=My PR' -o 'description=Details' -u origin pr/branch");
222 println!(" for multiline descriptions, use \\n:");
223 println!(
224 " git push -o 'title=My PR' -o 'description=line1\\n\\nline2' -u origin pr/branch"
225 );
179 println!("- publish a repository to nostr with `ngit init`"); 226 println!("- publish a repository to nostr with `ngit init`");
180 return Ok(None); 227 return Ok(None);
181 }; 228 };
@@ -222,3 +269,63 @@ async fn fetching_with_report_for_helper(
222 } 269 }
223 Ok(()) 270 Ok(())
224} 271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn decode_backslash_n_to_newline() {
279 assert_eq!(
280 decode_push_option_escapes(r"first line\nsecond line"),
281 "first line\nsecond line"
282 );
283 }
284
285 #[test]
286 fn decode_multiple_newlines() {
287 assert_eq!(
288 decode_push_option_escapes(r"line1\n\nline3\nline4"),
289 "line1\n\nline3\nline4"
290 );
291 }
292
293 #[test]
294 fn decode_double_backslash_n_to_literal_backslash_n() {
295 assert_eq!(
296 decode_push_option_escapes(r"keep \\n literal"),
297 "keep \\n literal"
298 );
299 }
300
301 #[test]
302 fn decode_no_escapes_unchanged() {
303 assert_eq!(
304 decode_push_option_escapes("no escapes here"),
305 "no escapes here"
306 );
307 }
308
309 #[test]
310 fn decode_trailing_backslash_preserved() {
311 assert_eq!(decode_push_option_escapes(r"ends with \"), "ends with \\");
312 }
313
314 #[test]
315 fn decode_backslash_followed_by_other_char_preserved() {
316 assert_eq!(decode_push_option_escapes(r"a \t tab"), "a \\t tab");
317 }
318
319 #[test]
320 fn decode_empty_string() {
321 assert_eq!(decode_push_option_escapes(""), "");
322 }
323
324 #[test]
325 fn decode_mixed_escapes() {
326 assert_eq!(
327 decode_push_option_escapes(r"line1\nline2\\nstill line2\nline3"),
328 "line1\nline2\\nstill line2\nline3"
329 );
330 }
331}
diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs
index 85c74cd..ade7861 100644
--- a/src/bin/ngit/cli.rs
+++ b/src/bin/ngit/cli.rs
@@ -8,7 +8,7 @@ use crate::sub_commands;
8#[command( 8#[command(
9 author, 9 author,
10 version, 10 version,
11 help_template = "{name} {version}\nnostr plugin for git\n - clone a nostr repository, or add as a remote, by using the url format nostr://pub123/identifier\n - remote branches beginning with `pr/` are open PRs from contributors; `ngit list` can be used to view all PRs\n - to open a PR, push a branch with the prefix `pr/` or use `ngit send` for advanced options\n- publish a repository to nostr with `ngit init`\n\n{usage}\n{all-args}" 11 help_template = "{name} {version}\nnostr plugin for git\n - clone a nostr repository, or add as a remote, by using the url format nostr://npub123/identifier\n - remote branches beginning with `pr/` are open PRs from contributors; `ngit list` can be used to view all PRs\n - to open a PR, push a branch with the prefix `pr/` or use `ngit send` for advanced options\n set title and description via push options:\n git push -o 'title=My PR' -o 'description=line1\\n\\nline2' -u origin pr/branch\n- publish a repository to nostr with `ngit init`\n\n{usage}\n{all-args}"
12)] 12)]
13#[command(propagate_version = true)] 13#[command(propagate_version = true)]
14#[allow(clippy::struct_excessive_bools)] 14#[allow(clippy::struct_excessive_bools)]
diff --git a/tests/git_remote_nostr/push.rs b/tests/git_remote_nostr/push.rs
index 38b1f8c..8498958 100644
--- a/tests/git_remote_nostr/push.rs
+++ b/tests/git_remote_nostr/push.rs
@@ -1895,6 +1895,114 @@ async fn push_new_pr_branch_with_title_description_options_creates_pr_with_custo
1895 Ok(()) 1895 Ok(())
1896} 1896}
1897 1897
1898#[tokio::test]
1899#[serial]
1900async fn push_with_escaped_newlines_in_description_creates_pr_with_multiline_description()
1901-> Result<()> {
1902 let (events, source_git_repo) = prep_source_repo_and_events_including_proposals().await?;
1903 let _source_path = source_git_repo.dir.to_str().unwrap().to_string();
1904
1905 let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = (
1906 Relay::new(8051, None, None),
1907 Relay::new(8052, None, None),
1908 Relay::new(8053, None, None),
1909 Relay::new(8055, None, None),
1910 Relay::new(8056, None, None),
1911 Relay::new(8057, None, None),
1912 );
1913 r51.events = events.clone();
1914 r55.events = events.clone();
1915
1916 #[allow(clippy::mutable_key_type)]
1917 let before = r55.events.iter().cloned().collect::<HashSet<Event>>();
1918 let branch_name = "pr/my-pr-multiline";
1919
1920 let cli_tester_handle = std::thread::spawn(move || -> Result<String> {
1921 let mut git_repo = clone_git_repo_with_nostr_url()?;
1922 git_repo.delete_dir_on_drop = false;
1923 git_repo.create_branch(branch_name)?;
1924 git_repo.checkout(branch_name)?;
1925
1926 let large_content = "x".repeat(70 * 1024);
1927 std::fs::write(git_repo.dir.join("large_file.txt"), large_content)?;
1928 git_repo.stage_and_commit("large_file.txt")?;
1929
1930 // Use \\n in the push-option value — the two-character escape sequence
1931 let mut p = CliTester::new_git_with_remote_helper_from_dir(
1932 &git_repo.dir,
1933 [
1934 "push",
1935 "--push-option=title=Multiline PR",
1936 r"--push-option=description=First line\n\nSecond paragraph\nThird line",
1937 "-u",
1938 "origin",
1939 branch_name,
1940 ],
1941 );
1942 cli_expect_nostr_fetch(&mut p)?;
1943 p.expect("git servers: listing refs...\r\n")?;
1944 p.expect_eventually_and_print(format!("To {}\r\n", get_nostr_remote_url()?).as_str())?;
1945 let output = p.expect_end_eventually()?;
1946
1947 for p in [51, 52, 53, 55, 56, 57] {
1948 relay::shutdown_relay(8000 + p)?;
1949 }
1950
1951 Ok(output)
1952 });
1953 let _ = join!(
1954 r51.listen_until_close(),
1955 r52.listen_until_close(),
1956 r53.listen_until_close(),
1957 r55.listen_until_close(),
1958 r56.listen_until_close(),
1959 r57.listen_until_close(),
1960 );
1961
1962 let output = cli_tester_handle.join().unwrap()?;
1963
1964 assert_eq!(
1965 output,
1966 format!(" * [new branch] {branch_name} -> {branch_name}\r\nbranch '{branch_name}' set up to track 'origin/{branch_name}'.\r\n").as_str(),
1967 );
1968
1969 let new_events = r55
1970 .events
1971 .iter()
1972 .cloned()
1973 .collect::<HashSet<Event>>()
1974 .difference(&before)
1975 .cloned()
1976 .collect::<Vec<Event>>();
1977 assert_eq!(new_events.len(), 1, "should create exactly 1 PR event");
1978
1979 let pr_event = new_events.first().unwrap();
1980
1981 assert!(
1982 pr_event.kind.eq(&KIND_PULL_REQUEST),
1983 "event should be a PR event"
1984 );
1985
1986 let title_tag = pr_event.tags.iter().find(|t| t.as_slice()[0].eq("subject"));
1987 assert!(
1988 title_tag.is_some(),
1989 "PR event should have a subject tag for title"
1990 );
1991 assert_eq!(
1992 title_tag.unwrap().as_slice()[1],
1993 "Multiline PR",
1994 "title should match push-option"
1995 );
1996
1997 // The \\n sequences should have been decoded into real newlines
1998 assert_eq!(
1999 pr_event.content, "First line\n\nSecond paragraph\nThird line",
2000 "description should contain real newlines from escaped \\n sequences"
2001 );
2002
2003 Ok(())
2004}
2005
1898mod push_from_another_maintainer { 2006mod push_from_another_maintainer {
1899 2007
1900 // TODO that has issued announcement 2008 // TODO that has issued announcement