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:
@@ -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
@@ -67,7 +67,7 @@ func (e *GitError) Error() string {
|
|||||||
func (e *GitError) Unwrap() error { return e.Err }
|
func (e *GitError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
// CommitOptions controls a synthetic commit. Empty identities and timestamps
|
// 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 {
|
type CommitOptions struct {
|
||||||
Message string
|
Message string
|
||||||
Parents []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
|
// CommitTreeWithOptions creates a synthetic commit without invoking hooks or
|
||||||
// consulting the user's Git identity. It does not update any ref.
|
// 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) {
|
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
|
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 {
|
func (s *GitStore) now() time.Time {
|
||||||
if s.Now != nil {
|
if s.Now != nil {
|
||||||
return s.Now()
|
return s.Now()
|
||||||
|
|||||||
+13
-1
@@ -757,7 +757,19 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
|||||||
message = "Accept " + proposal.ID
|
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)
|
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 {
|
if err != nil {
|
||||||
return AcceptResult{}, err
|
return AcceptResult{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestConcurrentDisjointAcceptancesSerialize(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||||
|
|||||||
Reference in New Issue
Block a user