From 5cc8ae28cb462d2cb0b7f74dfa5238e62d17eb70 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 17 Feb 2026 12:11:33 +0000 Subject: 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. --- CHANGELOG.md | 1 + src/bin/git_remote_nostr/main.rs | 113 +++++++++++++++++++++++++++++++++++++-- src/bin/ngit/cli.rs | 2 +- tests/git_remote_nostr/push.rs | 108 +++++++++++++++++++++++++++++++++++++ 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 - `ngit list --status` - filter by status (open,draft,closed,applied) - `ngit init --hashtag` - specify repository hashtag - Push options for PR title/description: `git push --push-option=title="..." --push-option=description="..."` + - 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. ### Removed 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 { description: Option, } +/// Decode escape sequences in push-option values. +/// +/// Git push-options are transmitted one per line, so literal newlines +/// cannot appear in a value. To support multiline titles and +/// descriptions users can write the two-character sequence `\n` which +/// this function converts to a real newline. A literal backslash +/// before `n` can be preserved by doubling it (`\\n`). +/// +/// # Examples +/// ```text +/// "first line\\nsecond line" -> "first line\nsecond line" +/// "keep \\\\n literal" -> "keep \\n literal" +/// "no escapes here" -> "no escapes here" +/// ``` +fn decode_push_option_escapes(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.peek() { + Some('n') => { + chars.next(); + result.push('\n'); + } + Some('\\') => { + chars.next(); + result.push('\\'); + } + _ => result.push(c), + } + } else { + result.push(c); + } + } + result +} + impl PushOptions { fn validate(&self) -> Result> { match (&self.title, &self.description) { @@ -111,8 +148,12 @@ async fn main() -> Result<()> { let option = rest.join(" "); if let Some((key, value)) = option.split_once('=') { match key { - "title" => push_options.title = Some(value.to_string()), - "description" => push_options.description = Some(value.to_string()), + "title" => { + push_options.title = Some(decode_push_option_escapes(value)); + } + "description" => { + push_options.description = Some(decode_push_option_escapes(value)); + } _ => {} } } @@ -168,7 +209,7 @@ async fn process_args() -> Result> { println!("nostr plugin for git"); println!("Usage:"); println!( - " - clone a nostr repository, or add as a remote, by using the url format nostr://pub123/identifier" + " - clone a nostr repository, or add as a remote, by using the url format nostr://npub123/identifier" ); println!( " - 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> { println!( " - to open a PR, push a branch with the prefix `pr/` or use `ngit send` for advanced options" ); + println!(" - set PR title/description via push options:"); + println!(" git push -o 'title=My PR' -o 'description=Details' -u origin pr/branch"); + println!(" for multiline descriptions, use \\n:"); + println!( + " git push -o 'title=My PR' -o 'description=line1\\n\\nline2' -u origin pr/branch" + ); println!("- publish a repository to nostr with `ngit init`"); return Ok(None); }; @@ -222,3 +269,63 @@ async fn fetching_with_report_for_helper( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_backslash_n_to_newline() { + assert_eq!( + decode_push_option_escapes(r"first line\nsecond line"), + "first line\nsecond line" + ); + } + + #[test] + fn decode_multiple_newlines() { + assert_eq!( + decode_push_option_escapes(r"line1\n\nline3\nline4"), + "line1\n\nline3\nline4" + ); + } + + #[test] + fn decode_double_backslash_n_to_literal_backslash_n() { + assert_eq!( + decode_push_option_escapes(r"keep \\n literal"), + "keep \\n literal" + ); + } + + #[test] + fn decode_no_escapes_unchanged() { + assert_eq!( + decode_push_option_escapes("no escapes here"), + "no escapes here" + ); + } + + #[test] + fn decode_trailing_backslash_preserved() { + assert_eq!(decode_push_option_escapes(r"ends with \"), "ends with \\"); + } + + #[test] + fn decode_backslash_followed_by_other_char_preserved() { + assert_eq!(decode_push_option_escapes(r"a \t tab"), "a \\t tab"); + } + + #[test] + fn decode_empty_string() { + assert_eq!(decode_push_option_escapes(""), ""); + } + + #[test] + fn decode_mixed_escapes() { + assert_eq!( + decode_push_option_escapes(r"line1\nline2\\nstill line2\nline3"), + "line1\nline2\\nstill line2\nline3" + ); + } +} 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; #[command( author, version, - 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}" + 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}" )] #[command(propagate_version = true)] #[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 Ok(()) } +#[tokio::test] +#[serial] +async fn push_with_escaped_newlines_in_description_creates_pr_with_multiline_description() +-> Result<()> { + let (events, source_git_repo) = prep_source_repo_and_events_including_proposals().await?; + let _source_path = source_git_repo.dir.to_str().unwrap().to_string(); + + let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( + Relay::new(8051, None, None), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + Relay::new(8057, None, None), + ); + r51.events = events.clone(); + r55.events = events.clone(); + + #[allow(clippy::mutable_key_type)] + let before = r55.events.iter().cloned().collect::>(); + let branch_name = "pr/my-pr-multiline"; + + let cli_tester_handle = std::thread::spawn(move || -> Result { + let mut git_repo = clone_git_repo_with_nostr_url()?; + git_repo.delete_dir_on_drop = false; + git_repo.create_branch(branch_name)?; + git_repo.checkout(branch_name)?; + + let large_content = "x".repeat(70 * 1024); + std::fs::write(git_repo.dir.join("large_file.txt"), large_content)?; + git_repo.stage_and_commit("large_file.txt")?; + + // Use \\n in the push-option value — the two-character escape sequence + let mut p = CliTester::new_git_with_remote_helper_from_dir( + &git_repo.dir, + [ + "push", + "--push-option=title=Multiline PR", + r"--push-option=description=First line\n\nSecond paragraph\nThird line", + "-u", + "origin", + branch_name, + ], + ); + cli_expect_nostr_fetch(&mut p)?; + p.expect("git servers: listing refs...\r\n")?; + p.expect_eventually_and_print(format!("To {}\r\n", get_nostr_remote_url()?).as_str())?; + let output = p.expect_end_eventually()?; + + for p in [51, 52, 53, 55, 56, 57] { + relay::shutdown_relay(8000 + p)?; + } + + Ok(output) + }); + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + r57.listen_until_close(), + ); + + let output = cli_tester_handle.join().unwrap()?; + + assert_eq!( + output, + format!(" * [new branch] {branch_name} -> {branch_name}\r\nbranch '{branch_name}' set up to track 'origin/{branch_name}'.\r\n").as_str(), + ); + + let new_events = r55 + .events + .iter() + .cloned() + .collect::>() + .difference(&before) + .cloned() + .collect::>(); + assert_eq!(new_events.len(), 1, "should create exactly 1 PR event"); + + let pr_event = new_events.first().unwrap(); + + assert!( + pr_event.kind.eq(&KIND_PULL_REQUEST), + "event should be a PR event" + ); + + let title_tag = pr_event.tags.iter().find(|t| t.as_slice()[0].eq("subject")); + assert!( + title_tag.is_some(), + "PR event should have a subject tag for title" + ); + assert_eq!( + title_tag.unwrap().as_slice()[1], + "Multiline PR", + "title should match push-option" + ); + + // The \\n sequences should have been decoded into real newlines + assert_eq!( + pr_event.content, "First line\n\nSecond paragraph\nThird line", + "description should contain real newlines from escaped \\n sequences" + ); + + Ok(()) +} + mod push_from_another_maintainer { // TODO that has issued announcement -- cgit v1.2.3