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
37 changes: 37 additions & 0 deletions crates/devkit-git/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,43 @@ impl VersionControl for GitBackend {
.to_string())
}

fn author(&self, dir: &Path) -> Result<Option<(String, String)>> {
// GIT_AUTHOR_* wins, and one half set alone does not mix with
// config. author.* overrides user.*.
let from_env = ["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL"]
.map(|key| std::env::var(key).ok().filter(|v| !v.trim().is_empty()));
let [name, email] = match from_env {
[Some(name), Some(email)] => [name, email],
[None, None] => ["name", "email"].map(|half| {
for key in [format!("author.{half}"), format!("user.{half}")] {
if let Ok(value) = Git::at(dir).args(["config", &key]).output() {
let value = value.trim().to_string();
if !value.is_empty() {
return value;
}
}
}
String::new()
}),
_ => return Ok(None),
};
Ok((!name.trim().is_empty() && !email.trim().is_empty()).then_some((name, email)))
}
Comment on lines +363 to +384

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Treat a partial GIT_AUTHOR pair as git does

When an agent caller has coauthors required for agents and no --coauthor, devkit commit exempts the commit only if VersionControl::author returns a GitHub bot, Claude, or Codex address. GitBackend::author returns Ok(None) whenever exactly one of GIT_AUTHOR_NAME and GIT_AUTHOR_EMAIL is set, even when the set half is a bot email and the missing half is filled from author.* or user.*. Git itself does not do that: a set GIT_AUTHOR_EMAIL overrides the email, and the unset name comes from GIT_AUTHOR_NAME, then author.name, then user.name (and the reverse for a name-only pair). A harness that exports only GIT_AUTHOR_EMAIL=261865991+grok-by-xai[bot]@users.noreply.github.com, or only GIT_AUTHOR_NAME, while the other half lives in git config, therefore gets "devkit commit needs --coauthor=... (required for agents)" and HEAD stays put, even though the commit git would record is already that agent. The same lookup is what the skill text calls the address the commit would record. Resolve each half the way git does, and return Ok(None) only when that half is still unset or blank.

Suggested change
fn author(&self, dir: &Path) -> Result<Option<(String, String)>> {
// GIT_AUTHOR_* wins, and one half set alone does not mix with
// config. author.* overrides user.*.
let from_env = ["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL"]
.map(|key| std::env::var(key).ok().filter(|v| !v.trim().is_empty()));
let [name, email] = match from_env {
[Some(name), Some(email)] => [name, email],
[None, None] => ["name", "email"].map(|half| {
for key in [format!("author.{half}"), format!("user.{half}")] {
if let Ok(value) = Git::at(dir).args(["config", &key]).output() {
let value = value.trim().to_string();
if !value.is_empty() {
return value;
}
}
}
String::new()
}),
_ => return Ok(None),
};
Ok((!name.trim().is_empty() && !email.trim().is_empty()).then_some((name, email)))
}
fn author(&self, dir: &Path) -> Result<Option<(String, String)>> {
// Each half is independent, as git records it: a set GIT_AUTHOR_*
// half wins, and an unset half falls through to author.* then user.*.
// A blank half is not an author.
let halves = ["NAME", "EMAIL"].map(|half| {
let from_env = std::env::var(format!("GIT_AUTHOR_{half}"))
.ok()
.filter(|v| !v.trim().is_empty());
if let Some(value) = from_env {
return value;
}
let key = half.to_ascii_lowercase();
for config_key in [format!("author.{key}"), format!("user.{key}")] {
if let Ok(value) = Git::at(dir).args(["config", &config_key]).output() {
let value = value.trim().to_string();
if !value.is_empty() {
return value;
}
}
}
String::new()
});
let [name, email] = halves;
Ok((!name.trim().is_empty() && !email.trim().is_empty()).then_some((name, email)))
}


fn head_author(&self, dir: &Path) -> Result<Option<(String, String)>> {
let out = Git::at(dir)
.args(["log", "-1", "--format=%an%x00%ae"])
.wait()?;
if !out.status.success() {
return Ok(None);
}
let text = String::from_utf8_lossy(&out.stdout);
let mut parts = text.trim_end_matches(['\n', '\r']).splitn(2, '\0');
let name = parts.next().unwrap_or_default().to_string();
let email = parts.next().unwrap_or_default().to_string();
Ok((!name.trim().is_empty() && !email.trim().is_empty()).then_some((name, email)))
}

fn commit(&self, dir: &Path, selection: &Selection<'_>, message: &str) -> Result<String> {
super::commit::commit(dir, selection, message)
}
Expand Down
88 changes: 80 additions & 8 deletions crates/devkit-ports/src/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use anyhow::{Context, Result, anyhow, ensure};
use devkit_common::{
caller::Caller,
record::{self, IssueRecord},
required::{ensure_supplied, ensure_supplied_as, missing_args},
required::{declared_required, ensure_supplied, ensure_supplied_as, missing_args},
template,
vcs::{self, Vcs, VersionControl},
};
Expand Down Expand Up @@ -189,6 +189,7 @@ pub fn commit_message(
message: &CommitMessage<'_>,
given: &BTreeMap<String, String>,
caller: Caller,
amend: bool,
) -> Result<String> {
render_commit_parts(
cfg,
Expand All @@ -197,6 +198,7 @@ pub fn commit_message(
message,
given,
caller,
amend,
)
}

Expand All @@ -208,16 +210,86 @@ fn render_commit_parts(
message: &CommitMessage<'_>,
given: &BTreeMap<String, String>,
caller: Caller,
amend: bool,
) -> Result<String> {
let coauthors = message.coauthors.join("; ");
let parts = [
message.subject,
message.body,
(!coauthors.is_empty()).then_some(coauthors.as_str()),
];
let coauthors = message
.coauthors
.iter()
.filter(|c| !c.trim().is_empty())
.cloned()
.collect::<Vec<_>>()
.join("; ");
// An agent author meets required = "agents" without a trailer. The
// stand-in is empty so the built-in template emits no Co-authored-by
// line, and the supplied body is left as the caller wrote it.
let agent = coauthors.is_empty()
&& builtin_commit_message(cfg)
&& declared_required(cfg, None, "coauthors") == Required::Agents
&& agent_author(start, caller, amend)?.is_some();
let supplied = agent
.then_some("")
.or((!coauthors.is_empty()).then_some(coauthors.as_str()));
let parts = [message.subject, message.body, supplied];
render_commit_message(cfg, start, parts, given, caller, what)
}

/// The exemption applies only to the unchanged built-in `commit_message`.
/// A `[templates].commit_message` override and a custom template of that
/// name still require `--coauthor`.
fn builtin_commit_message(cfg: &Config) -> bool {
cfg.templates.commit_message.is_none() && !cfg.templates.custom.contains_key(COMMIT_MESSAGE)
}

/// The git author when it is the agent, as `Name <email>`. A person's author
/// and a human caller get `None`. An amend keeps the existing commit's
/// author, so that author is the one checked.
fn agent_author(start: &Path, caller: Caller, amend: bool) -> Result<Option<String>> {
if caller != Caller::Agent {
return Ok(None);
}
let Some((name, email)) = (if amend {
Vcs::at(start).head_author(start)?
} else {
Vcs::at(start).author(start)?
}) else {
return Ok(None);
};
if !agent_email(&email) {
return Ok(None);
}
Ok(Some(format!("{} <{}>", name.trim(), email.trim())))
}

/// Addresses an agent harness records when it authors a commit itself, and
/// that have no GitHub bot form: Claude and Codex.
const AGENT_EMAILS: [&str; 2] = ["noreply@anthropic.com", "noreply@openai.com"];

/// A GitHub bot address, or one of [`AGENT_EMAILS`]. The bot form is a
/// numeric id, `+`, a nonempty name, and a terminal `[bot]`, at
/// `users.noreply.github.com`.
fn agent_email(email: &str) -> bool {
let email = email.trim();
if AGENT_EMAILS
.iter()
.any(|known| email.eq_ignore_ascii_case(known))
{
return true;
}
let Some((local, host)) = email.rsplit_once('@') else {
return false;
};
if !host.eq_ignore_ascii_case("users.noreply.github.com") {
return false;
}
let Some((id, rest)) = local.split_once('+') else {
return false;
};
let Some(name) = rest.strip_suffix("[bot]") else {
return false;
};
!id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()) && !name.is_empty()
}

/// Whether `devkit commit` can leave out `part`: every part but the subject,
/// unless `[templates.variables]` declares it.
fn optional_part(cfg: &Config, part: &str) -> bool {
Expand Down Expand Up @@ -387,7 +459,7 @@ pub fn render(
);
}
let what = format!("template `{name}`");
return render_commit_parts(cfg, start, (&what, names), parts, given, caller);
return render_commit_parts(cfg, start, (&what, names), parts, given, caller, false);
}
let [subject, body, coauthor] = COMMIT_PARTS.map(|(_, flag)| names.part(flag));
ensure!(
Expand Down
27 changes: 27 additions & 0 deletions crates/devkit-vcs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,29 @@ pub trait VersionControl {
/// configured.
fn user_email(&self, dir: &::std::path::Path) -> ::anyhow::Result<::std::string::String>;

/// The author a new commit in `dir` would record, or `Ok(None)` when
/// either half is unset. A complete `GIT_AUTHOR_NAME` and
/// `GIT_AUTHOR_EMAIL` pair wins. One half set, or either half blank, is
/// not an author. With neither set, `author.name` wins before
/// `user.name` and `author.email` wins before `user.email`.
fn author(
&self,
dir: &::std::path::Path,
) -> ::anyhow::Result<Option<(::std::string::String, ::std::string::String)>> {
let _ = dir;
Ok(None)
}

/// The author of the commit `HEAD` names, or `Ok(None)` when there is no
/// commit. An amend keeps this author.
fn head_author(
&self,
dir: &::std::path::Path,
) -> ::anyhow::Result<Option<(::std::string::String, ::std::string::String)>> {
let _ = dir;
Ok(None)
}

/// Commits `selection` in `dir`'s repository with `message`, leaving the
/// working tree, and changes anyone else staged, as they were. Refuses
/// rather than committing part of the selection. Returns the backend's
Expand Down Expand Up @@ -431,6 +454,10 @@ mod tests {
fn user_email(&self, _: &Path) -> anyhow::Result<String> {
unimplemented!()
}

fn author(&self, _: &Path) -> anyhow::Result<Option<(String, String)>> {
Ok(None)
}
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion plugin/skills/using-devkit/references/commit.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ devkit commit --amend --subject '...' # a new message for the last
```

- To commit part of a file, write only the hunks to commit as a patch against HEAD (`git diff -- path > hunks.patch`, then trim it) and pass it to `--patch`. A patch touching a path whose merge driver could not keep the staged hunks exact is refused.
- The message is the subject, then `--body` after a blank line, then a `Co-authored-by` trailer per `--coauthor`. `devkit template show commit_message` prints the project's template, each message part by its `devkit commit` flag with who must pass it, and any `--arg` it reads. Only `--subject` is required unless the project requires another part, as a project can require `--coauthor` of agents. `devkit template render commit_message` previews the message, taking `--subject`, `--body` and `--coauthor` as `devkit commit` does, and needs no more than it does.
- The message is the subject, then `--body` after a blank line, then a `Co-authored-by` trailer per `--coauthor`. `devkit template show commit_message` prints the project's template, each message part by its `devkit commit` flag with who must pass it, and any `--arg` it reads. Only `--subject` is required unless the project requires another part, as a project can require `--coauthor` of agents. An agent author meets a `required = "agents"` coauthor requirement with no trailer when the built-in `commit_message` is unchanged; `required = "always"` still needs `--coauthor`. `references/templates.md` says how. A custom `commit_message` still needs `--coauthor`. `devkit template render commit_message` previews the message, taking `--subject`, `--body` and `--coauthor` as `devkit commit` does, and needs no more than it does.
- Commit hooks and signing run as they do for `git commit`. A refused or failed commit leaves HEAD and the index unchanged, and says why. With `--files` or `--patch`, a commit hook that changes the committed tree is refused too.
- When the output says recovery files were retained, the new HEAD could not be confirmed: inspect `git log` before retrying, and do not run the commit again blind.
2 changes: 1 addition & 1 deletion plugin/skills/using-devkit/references/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ devkit template render <name> --arg-file k=notes.md # a multi-line value from a
```

- `render` prints the text byte for byte, so `> file` or a pipe gets it unchanged. `--json` on all three verbs emits JSON instead; `render --json` is `{"text": ...}`.
- A required arg left out is refused before anything renders, naming each `--arg` and its description the way `devrun task` does. An `--arg` the template never reads is refused too.
- A required arg left out is refused before anything renders, naming each `--arg` and its description the way `devrun task` does. An `--arg` the template never reads is refused too. With the unchanged built-in `commit_message`, an agent whose git author is already the agent meets a `coauthors` requirement marked `required = "agents"` without `--coauthor`. `required = "always"` still needs `--coauthor`, including when that author is a GitHub bot, Claude or Codex. The author email is the strict GitHub bot form (`123+name[bot]@users.noreply.github.com`: digits, `+`, a nonempty name, a terminal `[bot]`, that domain) or a built-in address, Claude `noreply@anthropic.com` or Codex `noreply@openai.com`. It comes from a complete `GIT_AUTHOR_NAME` and `GIT_AUTHOR_EMAIL` pair. One half set, or either half blank, is not an author. With neither variable set, `author.name` wins before `user.name` and `author.email` wins before `user.email`. An amend uses the author the commit already has. The commit then carries no `Co-authored-by` trailer. A person's author still needs `--coauthor`. A `[templates].commit_message` override and a `[templates.custom.commit_message]` override still need `--coauthor`. The other agent-only requirements stay.
- Every template sees `prefix` (`defaults.branch_prefix`), `branch`, and `ticket` (also named `issue`), `slug` and `apps` from the worktree's `.devkit/issue.toml`. Any other name it reads is an arg, including those the checkout lacks: outside a workspace, `render branch --arg slug=add-login` names a new branch. In one, `render pr_body --arg input=...` prints the body `devkit pr create --pr-body ...` would send.
- The built-ins are every template `workspace`, `ticket` and `devkit pr` render, `branch`, `worktree_dir`, `checkout_worktree_dir`, `issue_summary_path`, `issue_summary`, `pr_title`, `pr_body`, `ticket_title`, `ticket_body` (also rendered by their old names `issue_title` and `issue_body`), `review_request` and `review_finish`, plus `commit_message`, which `devkit commit` renders. `render` applies no length limit, so `branch_max` and the other `*_max` settings do not shorten its output, and `short_slug` is an arg.
- A project's own templates live under `[templates.custom.<name>]`, with a `body` and a `description`; `devkit schema` has the details. A custom template wins over a built-in of the same name.
Expand Down
1 change: 1 addition & 0 deletions src/bin/devkit/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ pub fn run(cli: CommitCli) -> Result<()> {
&cli.message.parts(),
&cli.vars.parse()?,
devkit_common::caller::caller(),
cli.amend,
)?;
// git applies the patch from the repository's top, not from here.
let patch = cli
Expand Down
Loading
Loading