Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 63 additions & 6 deletions crates/vedit-cli/src/author.rs
Original file line number Diff line number Diff line change
@@ -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<Author> {
pub fn resolve_authors() -> Result<Vec<Author>> {
let name = std::env::var("VEDIT_AUTHOR_NAME")
.ok()
.or_else(git_config_value("user.name"))
Expand All @@ -13,7 +10,35 @@ pub fn resolve() -> Result<Author> {
.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<Vec<Author>> {
raw.split(';')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(parse_author)
.collect()
}

fn parse_author(raw: &str) -> Result<Author> {
let Some((name, rest)) = raw.split_once('<') else {
bail!("invalid co-author {raw:?}: expected `Name <email>`");
};
let Some((email, trailing)) = rest.split_once('>') else {
bail!("invalid co-author {raw:?}: expected `Name <email>`");
};
if !trailing.trim().is_empty() || name.trim().is_empty() || email.trim().is_empty() {
bail!("invalid co-author {raw:?}: expected `Name <email>`");
}
Ok(Author {
name: name.trim().to_string(),
email: email.trim().to_string(),
})
}

fn git_config_value(key: &'static str) -> impl FnOnce() -> Option<String> {
Expand All @@ -34,3 +59,35 @@ fn git_config_value(key: &'static str) -> impl FnOnce() -> Option<String> {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parses_semicolon_separated_co_author_list() {
let authors =
parse_author_list("Alice Example <alice@example.com>; Vedit Agent <agent@vedit.local>")
.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 <email>`"));
}
}
4 changes: 2 additions & 2 deletions crates/vedit-cli/src/cmd/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
"[{} {}] {}",
Expand Down
22 changes: 21 additions & 1 deletion crates/vedit-cli/src/cmd/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
.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()
Expand Down
4 changes: 2 additions & 2 deletions crates/vedit-cli/src/cmd/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)?;

Expand Down
3 changes: 3 additions & 0 deletions crates/vedit-cli/src/cmd/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
4 changes: 2 additions & 2 deletions crates/vedit-cli/src/cmd/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
115 changes: 112 additions & 3 deletions crates/vedit-core/src/commit.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<String>,
/// Primary author, kept for compatibility with older callers.
pub author: Author,
/// Primary author followed by any co-authors.
pub authors: Vec<Author>,
pub timestamp: String,
pub message: String,
}
Expand All @@ -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<String>,
authors: Vec<Author>,
timestamp: String,
message: String,
) -> Result<Self> {
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<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct CommitWire {
schema: String,
timeline: String,
parents: Vec<String>,
author: Author,
#[serde(default)]
authors: Vec<Author>,
timestamp: String,
message: String,
}

let wire = CommitWire::deserialize(deserializer)?;
let authors = if wire.authors.is_empty() {
vec![wire.author]
} else {
wire.authors
Comment on lines +83 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve primary author during commit deserialization

When both author and authors are present, this logic discards author whenever authors is non-empty and then derives the primary author from authors[0]. That is a regression for mixed-format commit JSON (e.g., producers that treat authors as co-authors-only), because a commit that previously resolved to the explicit author will now silently change attribution to a different person. Deserialization should keep author authoritative (or validate/normalize consistency) instead of overwriting it from authors.

Useful? React with 👍 / 👎.

};
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()]);
}
}
32 changes: 26 additions & 6 deletions crates/vedit-core/src/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<Author>,
message: &str,
) -> Result<String> {
let parent = match self.head()? {
HeadState::Branch(name) => self.branch_target(&name)?,
HeadState::Detached(_) => {
Expand All @@ -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.
Expand Down Expand Up @@ -337,13 +347,23 @@ impl Repo {
author: Author,
message: &str,
) -> Result<String> {
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<String>,
authors: Vec<Author>,
message: &str,
) -> Result<String> {
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)?;

Expand Down
Loading