diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-17 12:11:33 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-17 14:04:32 +0000 |
| commit | 5cc8ae28cb462d2cb0b7f74dfa5238e62d17eb70 (patch) | |
| tree | 7e1f81c8204c3f50b30fa69b410f60ffa872bec7 /src | |
| parent | 06ebbf7c73b64225ae083bb3134f7c7c630c5565 (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.
Diffstat (limited to 'src')
| -rw-r--r-- | src/bin/git_remote_nostr/main.rs | 113 | ||||
| -rw-r--r-- | src/bin/ngit/cli.rs | 2 |
2 files changed, 111 insertions, 4 deletions
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 | /// ``` | ||
| 47 | fn 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 | |||
| 33 | impl PushOptions { | 70 | impl 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)] | ||
| 274 | mod 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)] |