diff --git a/crates/vedit-cli/src/author.rs b/crates/vedit-cli/src/author.rs index f1a84ce..43edf65 100644 --- a/crates/vedit-cli/src/author.rs +++ b/crates/vedit-cli/src/author.rs @@ -1,10 +1,7 @@ -use anyhow::Result; +use anyhow::{Result, bail}; use vedit_core::commit::Author; -/// Resolve commit authorship from env vars first, then a git config sniff, -/// then a default. We deliberately do not read .vedit/config yet; that -/// arrives when there's a repo-level config story. -pub fn resolve() -> Result { +pub fn resolve_authors() -> Result> { let name = std::env::var("VEDIT_AUTHOR_NAME") .ok() .or_else(git_config_value("user.name")) @@ -13,7 +10,35 @@ pub fn resolve() -> Result { .ok() .or_else(git_config_value("user.email")) .unwrap_or_else(|| "unknown@local".to_string()); - Ok(Author { name, email }) + let mut authors = vec![Author { name, email }]; + if let Ok(raw) = std::env::var("VEDIT_CO_AUTHORS") { + authors.extend(parse_author_list(&raw)?); + } + Ok(authors) +} + +fn parse_author_list(raw: &str) -> Result> { + raw.split(';') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(parse_author) + .collect() +} + +fn parse_author(raw: &str) -> Result { + let Some((name, rest)) = raw.split_once('<') else { + bail!("invalid co-author {raw:?}: expected `Name `"); + }; + let Some((email, trailing)) = rest.split_once('>') else { + bail!("invalid co-author {raw:?}: expected `Name `"); + }; + if !trailing.trim().is_empty() || name.trim().is_empty() || email.trim().is_empty() { + bail!("invalid co-author {raw:?}: expected `Name `"); + } + Ok(Author { + name: name.trim().to_string(), + email: email.trim().to_string(), + }) } fn git_config_value(key: &'static str) -> impl FnOnce() -> Option { @@ -34,3 +59,35 @@ fn git_config_value(key: &'static str) -> impl FnOnce() -> Option { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_semicolon_separated_co_author_list() { + let authors = + parse_author_list("Alice Example ; Vedit Agent ") + .unwrap(); + + assert_eq!( + authors, + vec![ + Author { + name: "Alice Example".to_string(), + email: "alice@example.com".to_string(), + }, + Author { + name: "Vedit Agent".to_string(), + email: "agent@vedit.local".to_string(), + } + ] + ); + } + + #[test] + fn rejects_invalid_co_author_entry() { + let err = parse_author_list("not an author").unwrap_err(); + assert!(err.to_string().contains("expected `Name `")); + } +} diff --git a/crates/vedit-cli/src/cmd/commit.rs b/crates/vedit-cli/src/cmd/commit.rs index 1860c44..46abaaa 100644 --- a/crates/vedit-cli/src/cmd/commit.rs +++ b/crates/vedit-cli/src/cmd/commit.rs @@ -26,8 +26,8 @@ pub fn run(timeline_path: &Path, message: Option<&str>) -> Result<()> { }; let timeline_hash = repo.write_timeline(&timeline_value)?; - let author = author::resolve()?; - let commit_hash = repo.commit(&timeline_hash, author, &resolved_message)?; + let authors = author::resolve_authors()?; + let commit_hash = repo.commit_with_authors(&timeline_hash, authors, &resolved_message)?; println!( "[{} {}] {}", diff --git a/crates/vedit-cli/src/cmd/log.rs b/crates/vedit-cli/src/cmd/log.rs index cc0ffed..b0c9556 100644 --- a/crates/vedit-cli/src/cmd/log.rs +++ b/crates/vedit-cli/src/cmd/log.rs @@ -32,15 +32,35 @@ pub fn run(refstr: &str) -> Result<()> { format!(" ({})", tags.join(", ")) }; println!( - "{} {}{}", + "{} {}{}{}", short(hash), commit.message.lines().next().unwrap_or(""), + author_summary(commit), tag_str ); } Ok(()) } +fn author_summary(commit: &vedit_core::commit::Commit) -> String { + match commit.authors.as_slice() { + [] => String::new(), + [author] => format!(" by {}", format_author(author)), + [author, rest @ ..] => format!( + " by {}; co-authors: {}", + format_author(author), + rest.iter() + .map(format_author) + .collect::>() + .join(", ") + ), + } +} + +fn format_author(author: &vedit_core::commit::Author) -> String { + format!("{} <{}>", author.name, author.email) +} + fn short(hash: &str) -> String { let body = hash.strip_prefix(object::HASH_PREFIX).unwrap_or(hash); body.chars().take(7).collect() diff --git a/crates/vedit-cli/src/cmd/merge.rs b/crates/vedit-cli/src/cmd/merge.rs index 3059c47..86dd589 100644 --- a/crates/vedit-cli/src/cmd/merge.rs +++ b/crates/vedit-cli/src/cmd/merge.rs @@ -119,10 +119,10 @@ pub fn run(target: &str, options: MergeOptions) -> Result<()> { .message .unwrap_or_else(|| format!("Merge branch '{target}' into {current_branch}")); - let commit_hash = repo.commit_with_parents( + let commit_hash = repo.commit_with_parents_and_authors( &timeline_hash, vec![head_hash.clone(), target_hash.clone()], - author::resolve()?, + author::resolve_authors()?, &message, )?; diff --git a/crates/vedit-cli/src/cmd/show.rs b/crates/vedit-cli/src/cmd/show.rs index 46d517d..27a424e 100644 --- a/crates/vedit-cli/src/cmd/show.rs +++ b/crates/vedit-cli/src/cmd/show.rs @@ -14,6 +14,9 @@ pub fn run(refstr: &str) -> Result<()> { println!("commit {}", short(&hash)); println!("Author: {} <{}>", commit.author.name, commit.author.email); + for co_author in commit.authors.iter().skip(1) { + println!("Co-author: {} <{}>", co_author.name, co_author.email); + } println!("Date: {}", commit.timestamp); println!(); for line in commit.message.lines() { diff --git a/crates/vedit-cli/src/cmd/watch.rs b/crates/vedit-cli/src/cmd/watch.rs index 09b17df..55da05b 100644 --- a/crates/vedit-cli/src/cmd/watch.rs +++ b/crates/vedit-cli/src/cmd/watch.rs @@ -158,8 +158,8 @@ fn attempt_commit( }; let timeline_hash = repo.write_timeline(&timeline_value)?; - let author = author::resolve()?; - let commit_hash = repo.commit(&timeline_hash, author, &message)?; + let authors = author::resolve_authors()?; + let commit_hash = repo.commit_with_authors(&timeline_hash, authors, &message)?; Ok(Some(CommitRecord { commit_hash, diff --git a/crates/vedit-core/src/commit.rs b/crates/vedit-core/src/commit.rs index e456d3b..fd9f962 100644 --- a/crates/vedit-core/src/commit.rs +++ b/crates/vedit-core/src/commit.rs @@ -1,8 +1,9 @@ //! Commit objects. -use serde::{Deserialize, Serialize}; +use anyhow::{Result, bail}; +use serde::{Deserialize, Deserializer, Serialize}; -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] pub struct Commit { /// Always `"vedit.commit.1"` for now. Future-proofs the on-disk format. pub schema: String, @@ -11,7 +12,10 @@ pub struct Commit { /// Hashes of parent commits. The initial commit has an empty array; /// non-merge commits have one parent; merges have two or more. pub parents: Vec, + /// Primary author, kept for compatibility with older callers. pub author: Author, + /// Primary author followed by any co-authors. + pub authors: Vec, pub timestamp: String, pub message: String, } @@ -32,13 +36,118 @@ impl Commit { timestamp: String, message: String, ) -> Self { - Self { + Self::new_with_authors(timeline, parents, vec![author], timestamp, message) + .expect("single-author commit always has one author") + } + + pub fn new_with_authors( + timeline: String, + parents: Vec, + authors: Vec, + timestamp: String, + message: String, + ) -> Result { + let Some(author) = authors.first().cloned() else { + bail!("commit requires at least one author"); + }; + Ok(Self { schema: Self::SCHEMA.to_string(), timeline, parents, author, + authors, timestamp, message, + }) + } +} + +impl<'de> Deserialize<'de> for Commit { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct CommitWire { + schema: String, + timeline: String, + parents: Vec, + author: Author, + #[serde(default)] + authors: Vec, + timestamp: String, + message: String, } + + let wire = CommitWire::deserialize(deserializer)?; + let authors = if wire.authors.is_empty() { + vec![wire.author] + } else { + wire.authors + }; + let author = authors + .first() + .cloned() + .ok_or_else(|| serde::de::Error::custom("commit requires at least one author"))?; + Ok(Self { + schema: wire.schema, + timeline: wire.timeline, + parents: wire.parents, + author, + authors, + timestamp: wire.timestamp, + message: wire.message, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn author(name: &str, email: &str) -> Author { + Author { + name: name.to_string(), + email: email.to_string(), + } + } + + #[test] + fn new_with_authors_preserves_primary_author_and_all_authors() { + let primary = author("Alice", "alice@example.com"); + let agent = author("Vedit Agent", "agent@vedit.local"); + + let commit = Commit::new_with_authors( + "timeline".to_string(), + vec!["parent".to_string()], + vec![primary.clone(), agent.clone()], + "2026-05-21T00:00:00Z".to_string(), + "pair edit".to_string(), + ) + .unwrap(); + + assert_eq!(commit.author, primary); + assert_eq!(commit.authors, vec![primary, agent]); + } + + #[test] + fn deserializes_legacy_single_author_commit_as_single_author_list() { + let value = json!({ + "schema": Commit::SCHEMA, + "timeline": "timeline", + "parents": [], + "author": { + "name": "Legacy", + "email": "legacy@example.com" + }, + "timestamp": "2026-05-21T00:00:00Z", + "message": "old" + }); + + let commit: Commit = serde_json::from_value(value).unwrap(); + + assert_eq!(commit.author.name, "Legacy"); + assert_eq!(commit.authors, vec![commit.author.clone()]); } } diff --git a/crates/vedit-core/src/repo.rs b/crates/vedit-core/src/repo.rs index 111d7b3..5e50bb4 100644 --- a/crates/vedit-core/src/repo.rs +++ b/crates/vedit-core/src/repo.rs @@ -295,6 +295,16 @@ impl Repo { /// the current HEAD (if any), and update HEAD's branch to point at /// it. Returns the new commit's hash. pub fn commit(&self, timeline_hash: &str, author: Author, message: &str) -> Result { + self.commit_with_authors(timeline_hash, vec![author], message) + } + + /// Create a commit with one primary author followed by any co-authors. + pub fn commit_with_authors( + &self, + timeline_hash: &str, + authors: Vec, + message: &str, + ) -> Result { let parent = match self.head()? { HeadState::Branch(name) => self.branch_target(&name)?, HeadState::Detached(_) => { @@ -303,13 +313,13 @@ impl Repo { }; let parents = parent.into_iter().collect(); - let commit = Commit::new( + let commit = Commit::new_with_authors( timeline_hash.to_string(), parents, - author, + authors, Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(), message.to_string(), - ); + )?; let commit_value = serde_json::to_value(&commit)?; let commit_hash = self.write_timeline(&commit_value)?; // ^ write_timeline writes any JSON object, not just timelines. @@ -337,13 +347,23 @@ impl Repo { author: Author, message: &str, ) -> Result { - let commit = Commit::new( + self.commit_with_parents_and_authors(timeline_hash, parents, vec![author], message) + } + + pub fn commit_with_parents_and_authors( + &self, + timeline_hash: &str, + parents: Vec, + authors: Vec, + message: &str, + ) -> Result { + let commit = Commit::new_with_authors( timeline_hash.to_string(), parents, - author, + authors, chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(), message.to_string(), - ); + )?; let commit_value = serde_json::to_value(&commit)?; let commit_hash = self.write_timeline(&commit_value)?;