Attribute accepted commits to the prompting user's configured Git identity

Hop-State: A_06FN8A13TXD2FW71C2B7MDR
Hop-Proposal: R_06FN89ZN929C6HYFDPJ2B48
Hop-Task: T_06FN892CVE210TZR2ZQQ9JG
Hop-Attempt: AT_06FN892CVDGHFG3F889Q3YR
This commit is contained in:
Hop
2026-07-11 19:29:04 -07:00
parent 787acb2330
commit 59b8ca7c05
4 changed files with 97 additions and 2 deletions
@@ -0,0 +1,17 @@
{
"id": "P_06FN892CVDG36AN981MGF00",
"task_id": "T_06FN892CVE210TZR2ZQQ9JG",
"attempt_id": "AT_06FN892CVDGHFG3F889Q3YR",
"state_id": "P_06FN892CVDG36AN981MGF00",
"prompt": "implement that",
"agent_name": "codex",
"status": "proposed",
"response_summary": "Attribute accepted commits to the prompting user's configured Git identity",
"created_at": "2026-07-12T02:24:52.955741Z",
"metadata": {
"source_tree": "5895f21f33fd3d6d04b764c84dfeb6e0c4738c1f",
"git_commit": "787acb2330d8662320477d022da37ee4029697ac",
"attempt_head": "C_06FN89NDDXYFZK3XRRBQPVR",
"attempt_head_kind": "checkpoint"
}
}
+40 -1
View File
@@ -67,7 +67,7 @@ func (e *GitError) Error() string {
func (e *GitError) Unwrap() error { return e.Err }
// CommitOptions controls a synthetic commit. Empty identities and timestamps
// use the GitStore defaults; user Git identity configuration is never used.
// use the GitStore defaults.
type CommitOptions struct {
Message string
Parents []string
@@ -478,6 +478,32 @@ func (r *Repository) CommitTree(ctx context.Context, tree string, parents []stri
})
}
// ConfiguredUserIdentity returns the Git identity configured for the user in
// this repository. Both user.name and user.email must be present.
func (r *Repository) ConfiguredUserIdentity(ctx context.Context) (GitIdentity, bool, error) {
name, nameSet, err := r.configValue(ctx, "user.name")
if err != nil {
return GitIdentity{}, false, err
}
email, emailSet, err := r.configValue(ctx, "user.email")
if err != nil {
return GitIdentity{}, false, err
}
if !nameSet || !emailSet {
return GitIdentity{}, false, nil
}
identity := GitIdentity{Name: name, Email: email}
if err := validateIdentity(identity); err != nil {
return GitIdentity{}, false, fmt.Errorf("invalid configured user identity: %w", err)
}
return identity, true, nil
}
// SyntheticIdentity is Hop's controlled committer identity.
func (r *Repository) SyntheticIdentity() GitIdentity {
return r.store.identity(GitIdentity{})
}
// CommitTreeWithOptions creates a synthetic commit without invoking hooks or
// consulting the user's Git identity. It does not update any ref.
func (r *Repository) CommitTreeWithOptions(ctx context.Context, tree string, options CommitOptions) (string, error) {
@@ -1303,6 +1329,19 @@ func (s *GitStore) identity(override GitIdentity) GitIdentity {
return identity
}
func (r *Repository) configValue(ctx context.Context, key string) (string, bool, error) {
output, err := r.run(ctx, nil, nil, "config", "--get", key)
if err != nil {
var gitErr *GitError
if errors.As(err, &gitErr) && gitErr.ExitCode == 1 {
return "", false, nil
}
return "", false, fmt.Errorf("read git config %s: %w", key, err)
}
value := trimLine(output)
return value, value != "", nil
}
func (s *GitStore) now() time.Time {
if s.Now != nil {
return s.Now()
+13 -1
View File
@@ -757,7 +757,19 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
message = "Accept " + proposal.ID
}
message += fmt.Sprintf("\n\nHop-State: %s\nHop-Proposal: %s\nHop-Task: %s\nHop-Attempt: %s\n", acceptedID, proposal.ID, proposal.TaskID, proposal.AttemptID)
commit, err := s.Repo.CommitTree(ctx, finalTree, []string{current.GitCommit}, message)
author, configured, err := s.Repo.ConfiguredUserIdentity(ctx)
if err != nil {
return AcceptResult{}, err
}
if !configured {
author = s.Repo.SyntheticIdentity()
}
commit, err := s.Repo.CommitTreeWithOptions(ctx, finalTree, CommitOptions{
Message: message,
Parents: []string{current.GitCommit},
Author: author,
Committer: s.Repo.SyntheticIdentity(),
})
if err != nil {
return AcceptResult{}, err
}
+27
View File
@@ -1059,6 +1059,33 @@ func TestDisjointProposalsLandAndUndoMovesForward(t *testing.T) {
}
}
func TestAcceptedCommitAttributesPromptingUserAndRetainsHopCommitter(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
runGitTest(t, service.Root, "config", "user.name", "Prompting User")
runGitTest(t, service.Root, "config", "user.email", "prompter@example.com")
attempt, err := service.CreatePrompt(ctx, "Add authored change", "", "codex")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(attempt.Workspace, "authored.txt"), "authored\n")
proposal, err := service.Propose(ctx, attempt.Prompt.ID, "Add authored change")
if err != nil {
t.Fatal(err)
}
accepted, err := service.Accept(ctx, proposal.Proposal.ID, nil)
if err != nil {
t.Fatal(err)
}
metadata := runGitTest(t, service.Root, "show", "-s", "--format=%an <%ae>%n%cn <%ce>", accepted.State.GitCommit)
want := "Prompting User <prompter@example.com>\nHop <hop@localhost>"
if metadata != want {
t.Fatalf("accepted commit identity = %q, want %q", metadata, want)
}
}
func TestConcurrentDisjointAcceptancesSerialize(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})