diff options
| author | tv <tv@krebsco.de> | 2026-11-02 11:46:57 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-02 11:46:57 +0100 |
| commit | b6e44154902fd3507852932b0aed9885ceb85fbf (patch) | |
| tree | ba6b5885c57a8b3f115ea4dca621457b3168bc12 | |
| parent | 315c0261ca1cfaf13cf4fde53642edc2bc63a73b (diff) | |
document: put user in commit message trailer
| -rw-r--r-- | docs/openapi.yaml | 5 | ||||
| -rw-r--r-- | src/api/changes.rs | 10 | ||||
| -rw-r--r-- | src/document/replica.rs | 75 |
3 files changed, 80 insertions, 10 deletions
diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4e07e8a..78912fa 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -558,8 +558,9 @@ paths: summary: Who wrote what description: | The document's changes, newest first: each with when (its writer's clock, Unix - ms; 0 for a remake's snapshot), the node that made it (`node`, its node ID) and, - for a write through the API, the user it was made for (`user`), and the store + ms; 0 for a remake's snapshot), the node that committed it (`node`, its node + ID) and, for a write through the API, the user at that node who wrote it + (`user`, from the message's `User:` trailer), and the store paths it touched (a list's items by index). Every node holds every change, so any node answers for the whole swarm, as far as it has synced; history before the last remake is gone with it (`remakes.md`). Changes made before nodes diff --git a/src/api/changes.rs b/src/api/changes.rs index ee6a74a..419f5ed 100644 --- a/src/api/changes.rs +++ b/src/api/changes.rs @@ -1,7 +1,7 @@ //! `GET /admin/changes[/<path>]`: who wrote what, newest first, of the whole document //! or of what is at a path (as `/store/<path>` names it). Every change names the node that -//! made it (its author) and, for a write through the API, the user it was made for (its -//! message), with when (its writer's clock) and the store paths it touched +//! committed it (its author) and, for a write through the API, the user who wrote it (in +//! its message, `User:`), with when (its writer's clock) and the store paths it touched //! (`document::replica::Writer`). Every node holds every change, so any node answers //! for the whole swarm, as far as it has synced. @@ -123,7 +123,11 @@ fn changes( .author .as_ref() .and_then(|author| String::from_utf8(author.as_bytes().to_vec()).ok()), - user: meta.message.as_ref().map(|message| message.to_string()), + user: meta + .message + .as_deref() + .and_then(crate::document::replica::user_of) + .map(str::to_owned), paths: paths.iter().map(|path| path.join("/")).collect(), }); } diff --git a/src/document/replica.rs b/src/document/replica.rs index 0d1998a..03325eb 100644 --- a/src/document/replica.rs +++ b/src/document/replica.rs @@ -340,20 +340,85 @@ mod tests { } #[test] + fn messages_name_their_user_in_a_trailer() { + let writer = Writer { + node: "node", + user: Some("tv"), + }; + let message = writer.message().unwrap(); + assert_eq!(message, "User: tv"); + assert_eq!(user_of(&message), Some("tv")); + assert_eq!( + user_of("Moved the settings.\n\nUser: tv\nSigned-off-by: someone"), + Some("tv") + ); + // Before trailers: the user itself. + assert_eq!(user_of("tv"), Some("tv")); + assert_eq!(user_of("Moved the settings."), None); + assert_eq!(user_of("Moved: the settings\nand more"), None); + assert_eq!(user_of("Signed-off-by: someone"), None); + } + + #[test] fn display() { assert_eq!(Epoch::GENESIS.to_string(), "0:genesis"); assert_eq!(epoch(2, 0xab).to_string(), "2:abababababab"); } } -/// Who makes a change, as it records: the node, as the change's author (its node ID), -/// and the user it wrote for through the API, if any, as its message. Every node holds -/// every change, so who wrote what is known swarm-wide (`GET /admin/changes`). +/// Who makes a change, as it records: the node that commits it, as its Automerge author +/// (its node ID), and the user it wrote for through the API, if any, in its message +/// (`User:`, `USER_TRAILER`). That user, at that node, is who wrote it: user names are +/// each node's own (`--htpasswd`). Every node holds every change, so who wrote what is +/// known swarm-wide (`GET /admin/changes`). pub(crate) struct Writer<'a> { pub(crate) node: &'a str, pub(crate) user: Option<&'a str>, } +/// The trailer of a change's message naming its user (`Writer`), as Git's +/// `Signed-off-by:` names who signed off. +pub(crate) const USER_TRAILER: &str = "User"; + +impl Writer<'_> { + /// The change's message: its trailers (`trailers`), if it has any. + fn message(&self) -> Option<String> { + self.user.map(|user| format!("{USER_TRAILER}: {user}")) + } +} + +/// The trailers of a change's message, as in Git: the `Key: value` lines of its last +/// paragraph, if each of its lines is one. +pub(crate) fn trailers(message: &str) -> Vec<(&str, &str)> { + let last = message.trim_end().rsplit("\n\n").next().unwrap_or(""); + let trailers: Option<Vec<(&str, &str)>> = last + .lines() + .map(|line| { + let (key, value) = line.split_once(": ")?; + (!key.is_empty() && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) + .then(|| (key, value.trim())) + }) + .collect(); + trailers.unwrap_or_default() +} + +/// The user a change's message names (`USER_TRAILER`). Changes made before messages had +/// trailers have the user as their whole message: one word, without a `:` +/// (`--htpasswd` names have none). +pub(crate) fn user_of(message: &str) -> Option<&str> { + let trailers = trailers(message); + if trailers.is_empty() { + let message = message.trim(); + let word = + !message.is_empty() && !message.contains(|c: char| c == ':' || c.is_whitespace()); + return word.then_some(message); + } + trailers + .into_iter() + .find(|(key, _)| key.eq_ignore_ascii_case(USER_TRAILER)) + .map(|(_, user)| user) +} + /// The writes `f` makes to `doc`, as one change stamped `at` by `writer` if it succeeds, /// dropped if not ([`Replica::transact`], and writes into the document before a rejoin). pub(crate) fn transact<T, E>( @@ -403,8 +468,8 @@ fn run<T, E>( } let mut options = automerge::transaction::CommitOptions::default().with_time(at.as_u64() as i64); - if let Some(user) = writer.user { - options.set_message(user); + if let Some(message) = writer.message() { + options.set_message(message); } let (_, logged) = transaction.commit_with(options); (out, Some(logged)) |
