diff --git a/rust/rhgitaly/src/lib.rs b/rust/rhgitaly/src/lib.rs
index 33189bb59352a0b658cdbdb36917911262cdd399_cnVzdC9yaGdpdGFseS9zcmMvbGliLnJz..6b7c31ba498ba2873b90f583e390b1c9fce36bbe_cnVzdC9yaGdpdGFseS9zcmMvbGliLnJz 100644
--- a/rust/rhgitaly/src/lib.rs
+++ b/rust/rhgitaly/src/lib.rs
@@ -16,6 +16,7 @@
 }
 
 pub mod config;
+pub mod message;
 pub mod repository;
 pub mod service;
 pub mod streaming;
diff --git a/rust/rhgitaly/src/message.rs b/rust/rhgitaly/src/message.rs
new file mode 100644
index 0000000000000000000000000000000000000000..6b7c31ba498ba2873b90f583e390b1c9fce36bbe_cnVzdC9yaGdpdGFseS9zcmMvbWVzc2FnZS5ycw==
--- /dev/null
+++ b/rust/rhgitaly/src/message.rs
@@ -0,0 +1,339 @@
+//! Adaptators for common Gitaly / HGitaly messages
+use hg::changelog::{ChangelogEntry, ChangelogRevisionData};
+use hg::errors::HgError;
+use hg::revlog::RevlogError;
+use std::fmt;
+
+use crate::gitaly::{CommitAuthor, GitCommit};
+use prost_types::Timestamp;
+use std::str::FromStr;
+
+#[derive(Debug, PartialEq)]
+pub enum TimeStampParseError {
+    Empty,
+    MissingTimeZone,
+    InvalidUtcTime(String),
+}
+
+impl fmt::Display for TimeStampParseError {
+    // This trait requires `fmt` with this exact signature.
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        // Write strictly the first element into the supplied output
+        // stream: `f`. Returns `fmt::Result` which indicates whether the
+        // operation succeeded or failed. Note that `write!` uses syntax which
+        // is very similar to `println!`.
+        match *self {
+            Self::Empty => write!(f, "empty timestamp line"),
+            Self::MissingTimeZone => write!(f, "timestamp line without a timezone"),
+            Self::InvalidUtcTime(ref s) => write!(f, "invalid UTC time in timestamp line: {}", s),
+        }
+    }
+}
+
+/// Parse the timestamp line from changelog data into numbers
+///
+/// This is meant to be equivalent to the reference Python implementation.
+/// See the `_raw_date` and `date` properties of class changelog.changelogrevision in the
+/// Python Mercurial reference implementation.
+/// In particular the Python implementation converts seconds to `float` (in practice, the data does
+/// never seem to contain a fractional part). Using `f64` because `f32` introduces rounding errors.
+/// TODO should be upstreamed to hg-core
+fn parse_timestamp_line(line: &[u8]) -> Result<(f64, i32), TimeStampParseError> {
+    if line.is_empty() {
+        return Err(TimeStampParseError::Empty);
+    }
+    let mut split = line.splitn(3, |b| *b == b' ');
+    let time_str =
+        String::from_utf8_lossy(split.next().expect("SplitN always emits at least once"));
+    let time = f64::from_str(&time_str)
+        .map_err(|e| TimeStampParseError::InvalidUtcTime(format!("{}", e)))?;
+    let tz_str = String::from_utf8_lossy(split.next().ok_or(TimeStampParseError::MissingTimeZone)?);
+    // according to reference implementation "Various tools did silly things with the timezone"
+    // and then it defaults to 0.
+    let tz_offset = i32::from_str(&tz_str).unwrap_or(0);
+    Ok((time, tz_offset))
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct PersonWithEmail {
+    person: Vec<u8>,
+    email: Option<Vec<u8>>,
+}
+
+/// Inspired by the [trim_ascii functions](std::primitite.slice::trim_ascii_end), with the following
+///
+/// diffenences:
+/// - we need to strip also double quotes
+/// - we don't care about being `const`
+fn trim_person_end(person: &[u8]) -> &[u8] {
+    let mut bytes = person;
+    while let [rest @ .., last] = bytes {
+        if *last == b' ' || *last == b'"' {
+            bytes = rest
+        } else {
+            break;
+        }
+    }
+    bytes
+}
+
+fn copy_cleanup_person(person: &[u8]) -> Vec<u8> {
+    let trimmed = trim_person_end(person);
+    let mut res = Vec::with_capacity(trimmed.len());
+    let mut it = trimmed.iter();
+
+    let mut at_start = true;
+    while let Some(b) = it.next() {
+        let current = *b;
+        if at_start && (current == b' ' || current == b'"') {
+            continue;
+        } else {
+            at_start = false
+        };
+
+        if current == b'\\' {
+            match it.next() {
+                None => {
+                    res.push(current);
+                }
+                Some(c) => {
+                    let after_backslash = *c;
+                    if after_backslash == b'"' {
+                        res.push(b'"');
+                    } else {
+                        res.push(current);
+                        res.push(after_backslash);
+                    }
+                }
+            }
+        } else {
+            res.push(current);
+        }
+    }
+    res
+}
+
+fn copy_replace_dot(s: &[u8]) -> Vec<u8> {
+    s.iter()
+        .map(|b| if *b == b'.' { b' ' } else { *b })
+        .collect()
+}
+
+/// Parse the full user line, typically in the form "Fullname <email address>"
+///
+/// This follows the Python reference implementation as `mercurial.utils.stringutil.person()`,
+/// itself interpreting the line as per RFC 5322.
+fn parse_user_line(line: &[u8]) -> PersonWithEmail {
+    let mut at_split = line.splitn(2, |b| *b == b'@');
+    let before_at = at_split.next().expect("SplitN always emits at least once");
+    match at_split.next() {
+        None => PersonWithEmail {
+            person: line.into(),
+            email: None,
+        },
+        Some(after_at) => {
+            let mut opening_email_split = before_at.splitn(2, |b| *b == b'<');
+            let person = copy_cleanup_person(
+                opening_email_split
+                    .next()
+                    .expect("SplitN always emits at least once"),
+            );
+            match opening_email_split.next() {
+                Some(email_before_at) => {
+                    let mut email_after_at_split = after_at.splitn(2, |b| *b == b'>');
+                    let email_after_at = email_after_at_split.next().expect("SplitN again");
+                    match email_after_at_split.next() {
+                        Some(_) => {
+                            let mut email = email_before_at.to_vec();
+                            email.push(b'@');
+                            email.extend_from_slice(email_after_at);
+                            PersonWithEmail {
+                                person,
+                                email: Some(email),
+                            }
+                        }
+                        None => PersonWithEmail {
+                            person,
+                            email: None,
+                        },
+                    }
+                }
+                None => PersonWithEmail {
+                    person: copy_replace_dot(before_at),
+                    email: Some(line.to_vec()),
+                },
+            }
+        }
+    }
+}
+
+/// Parse ChangelogEntry timestamp line, returning information as expected in Gitaly fields
+fn gitaly_timestamp_tz(timestamp_line: &[u8]) -> Result<(Timestamp, String), TimeStampParseError> {
+    let (seconds, mut tz) = parse_timestamp_line(timestamp_line)?;
+
+    let tz_sign = if tz <= 0 { '+' } else { '-' };
+    if tz <= 0 {
+        tz = -tz
+    };
+    let tz_minutes = tz / 60;
+    Ok((
+        Timestamp {
+            seconds: seconds as i64,
+            nanos: 0,
+        }, // TODO why not provide nanos, then?
+        format!("{}{:02}{:02}", tz_sign, tz_minutes / 60, tz_minutes % 60),
+    ))
+}
+
+pub fn commit<'changelog>(entry: ChangelogEntry<'changelog>) -> Result<GitCommit, RevlogError> {
+    let node_id = format!("{:x}", entry.as_revlog_entry().node());
+
+    let mut parent_ids = Vec::new();
+    if let Some(parent) = entry.p1_entry()? {
+        parent_ids.push(format!("{:x}", parent.as_revlog_entry().node()));
+    }
+    if let Some(parent) = entry.p2_entry()? {
+        parent_ids.push(format!("{:x}", parent.as_revlog_entry().node()));
+    }
+
+    let data: ChangelogRevisionData<'changelog> = entry.data()?;
+
+    let body = data.description();
+    // changeset description cannot be empty unless some manual tinkering occurred,
+    // but we are still treating it, hence returning an empty subhject in that case.
+    let subject = body.splitn(2, |b| *b == b'\n').next().unwrap_or(&[]);
+
+    let author = commit_author(&data)
+        .map_err(|e| HgError::CorruptedRepository(format!("Changeset {}: {}", node_id, e)))?;
+
+    Ok(GitCommit {
+        id: node_id,
+        parent_ids,
+        subject: subject.to_vec(),
+        body: body.to_vec(),
+        body_size: body.len() as i64,
+        committer: Some(author.clone()),
+        author: Some(author),
+        ..Default::default()
+    })
+}
+
+fn commit_author(cr_data: &ChangelogRevisionData) -> Result<CommitAuthor, TimeStampParseError> {
+    let person_with_email = parse_user_line(cr_data.user());
+    let (ts, tz) = gitaly_timestamp_tz(cr_data.timestamp_line())?;
+
+    Ok(CommitAuthor {
+        name: person_with_email.person,
+        email: person_with_email.email.unwrap_or_default(),
+        date: Some(ts),
+        timezone: tz.into(),
+    })
+}
+
+#[cfg(test)]
+mod tests {
+
+    use super::*;
+
+    #[test]
+    fn test_parse_timestamp_line() {
+        assert_eq!(
+            parse_timestamp_line(b"1679951671 0").unwrap(),
+            (1679951671.0, 0)
+        );
+        assert_eq!(
+            parse_timestamp_line(b"1626786076.0 0").unwrap(),
+            (1626786076.0, 0)
+        );
+        assert_eq!(
+            parse_timestamp_line(b"1626786076.0 -7200").unwrap(),
+            (1626786076.0, -7200)
+        );
+        assert_eq!(
+            parse_timestamp_line(b"1626786076.0 4500 any other stuff").unwrap(),
+            (1626786076.0, 4500)
+        );
+        assert_eq!(
+            parse_timestamp_line(b"16.a 0"),
+            Err(TimeStampParseError::InvalidUtcTime(
+                "invalid float literal".to_string()
+            ))
+        );
+        assert_eq!(
+            parse_timestamp_line(b"1626786076.0"),
+            Err(TimeStampParseError::MissingTimeZone),
+        );
+        assert_eq!(parse_timestamp_line(b""), Err(TimeStampParseError::Empty));
+    }
+
+    #[test]
+    fn test_gitaly_timestamp_tz() -> Result<(), TimeStampParseError> {
+        // full form
+        assert_eq!(
+            gitaly_timestamp_tz(b"1626786076.0 -7200")?,
+            (
+                Timestamp {
+                    seconds: 1626786076,
+                    nanos: 0
+                },
+                "+0200".to_string()
+            )
+        );
+        // various timezones
+        assert_eq!(&gitaly_timestamp_tz(b"0 -1800")?.1, "+0030");
+        assert_eq!(&gitaly_timestamp_tz(b"0 5400")?.1, "-0130");
+        Ok(())
+    }
+
+    /// These are the same examples as in Python's `mercurial.util.stringutil.person`
+    #[test]
+    fn test_parse_user_line() {
+        assert_eq!(
+            parse_user_line(b"foo@bar"),
+            PersonWithEmail {
+                person: b"foo".to_vec(),
+                email: Some(b"foo@bar".to_vec())
+            }
+        );
+        assert_eq!(
+            parse_user_line(b"Foo Bar <foo@bar>"),
+            PersonWithEmail {
+                person: b"Foo Bar".to_vec(),
+                email: Some(b"foo@bar".to_vec())
+            }
+        );
+        assert_eq!(
+            parse_user_line(b"\"Foo \\\"buz\\\" Bar\" <foo@bar>"),
+            PersonWithEmail {
+                person: b"Foo \"buz\" Bar".to_vec(),
+                email: Some(b"foo@bar".to_vec())
+            }
+        );
+        //  The following are invalid, but do exist in real-life
+        assert_eq!(
+            parse_user_line(b"Foo \"buz\" Bar <foo@bar>"),
+            PersonWithEmail {
+                person: b"Foo \"buz\" Bar".to_vec(),
+                email: Some(b"foo@bar".to_vec())
+            }
+        );
+        assert_eq!(
+            parse_user_line(b"\"Foo Bar <foo@bar>"),
+            PersonWithEmail {
+                person: b"Foo Bar".to_vec(),
+                email: Some(b"foo@bar".to_vec())
+            }
+        );
+    }
+
+    #[test]
+    fn more_test_parse_user_line() {
+        assert_eq!(
+            parse_user_line(b"some.foo@bar"),
+            PersonWithEmail {
+                person: b"some foo".to_vec(),
+                email: Some(b"some.foo@bar".to_vec())
+            }
+        );
+    }
+}
diff --git a/rust/rhgitaly/src/streaming.rs b/rust/rhgitaly/src/streaming.rs
index 33189bb59352a0b658cdbdb36917911262cdd399_cnVzdC9yaGdpdGFseS9zcmMvc3RyZWFtaW5nLnJz..6b7c31ba498ba2873b90f583e390b1c9fce36bbe_cnVzdC9yaGdpdGFseS9zcmMvc3RyZWFtaW5nLnJz 100644
--- a/rust/rhgitaly/src/streaming.rs
+++ b/rust/rhgitaly/src/streaming.rs
@@ -35,6 +35,7 @@
             // logging at error level because when we implement graceful
             // shutdown, we will try and avoid to write on channels that are
             // closing down.
+            // TODO take the opportunity to fully log the original error
             error!("Channel closing down or closed while streaming {}", msg)
         });
     }