diff --git a/cmd/entire/cli/agentimport/agentimport.go b/cmd/entire/cli/agentimport/agentimport.go index ab2bd30331..1a414d46a0 100644 --- a/cmd/entire/cli/agentimport/agentimport.go +++ b/cmd/entire/cli/agentimport/agentimport.go @@ -156,6 +156,12 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options) // whole import run pays for at most one history walk, not one per turn. anchorResolver := newTurnAnchorResolver(repo, opts.LinkCommitSHA, opts.Now) + // Resolve the importer's git identity once per run (not per turn): + // checkpoint commits otherwise carry an empty author, which the + // GitHub->mirror ingestion path falls back to when it has no pusher + // identity for imported sessions, leaving them unattributed. + authorName, authorEmail := cp.GetGitAuthorFromRepo(repo) + for _, sf := range files { res.SessionsScanned++ full, readErr := os.ReadFile(sf.Path) @@ -195,7 +201,7 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options) logging.Debug(ctx, "import: turn anchor fell back", "sessionID", sf.SessionID, "turnUUID", turn.UUID, "candidates", len(turn.CommitSHAs)) } - if err := writeTurn(ctx, stores, imp, cid, sf, red, turn, anchor); err != nil { + if err := writeTurn(ctx, stores, imp, cid, sf, red, turn, anchor, authorName, authorEmail); err != nil { return res, err } existing[cid.String()] = true @@ -286,7 +292,7 @@ func writeSessionState(ctx context.Context, imp Importer, sf SessionFile, turns return nil } -func writeTurn(ctx context.Context, stores *cp.Stores, imp Importer, cid id.CheckpointID, sf SessionFile, red redact.RedactedBytes, turn Turn, anchorCommitSHA string) error { +func writeTurn(ctx context.Context, stores *cp.Stores, imp Importer, cid id.CheckpointID, sf SessionFile, red redact.RedactedBytes, turn Turn, anchorCommitSHA, authorName, authorEmail string) error { if err := stores.Persistent.Write(ctx, cp.Session(cp.WriteOptions{ CheckpointID: cid, SessionID: sf.SessionID, @@ -301,6 +307,8 @@ func writeTurn(ctx context.Context, stores *cp.Stores, imp Importer, cid id.Chec CheckpointTranscriptStart: turn.LineStart, TokenUsage: turn.Tokens, CommitSHA: anchorCommitSHA, + AuthorName: authorName, + AuthorEmail: authorEmail, })); err != nil { return fmt.Errorf("write imported checkpoint %s: %w", cid, err) } diff --git a/cmd/entire/cli/agentimport/agentimport_test.go b/cmd/entire/cli/agentimport/agentimport_test.go index ea4c84af8e..424787d345 100644 --- a/cmd/entire/cli/agentimport/agentimport_test.go +++ b/cmd/entire/cli/agentimport/agentimport_test.go @@ -384,6 +384,122 @@ func TestRun_CursorImporterEndToEnd(t *testing.T) { } } +// TestRun_StampsImporterGitAuthorOnCheckpointCommit proves an imported +// checkpoint's underlying git commit on entire/checkpoints/v1 carries the +// importer's configured git identity (resolved once per Run via +// checkpoint.GetGitAuthorFromRepo), not an empty signature. +// +// Motivation: on the GitHub->mirror ingestion path, the data plane has no +// pusher identity for imported sessions and falls back to the checkpoint +// commit's git author. An empty author meant imported sessions couldn't be +// attributed to the importer. +func TestRun_StampsImporterGitAuthorOnCheckpointCommit(t *testing.T) { + t.Parallel() + repo, repoDir := initRepoWithCommit(t) + claudeDir := t.TempDir() + writeFixtureSession(t, claudeDir, "sess-author.jsonl") + + res, err := Run(context.Background(), repo, claudeImporter{}, Options{ + RepoRoot: repoDir, OverridePath: claudeDir, + Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC), + }) + if err != nil { + t.Fatal(err) + } + if res.TurnsImported != 2 { + t.Fatalf("want 2 imported, got %+v", res) + } + + stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{}) + if err != nil { + t.Fatal(err) + } + ar, ok := stores.Persistent.(cp.AuthorReader) + if !ok { + t.Fatalf("persistent store %T does not implement AuthorReader", stores.Persistent) + } + cid := DeriveCheckpointID("sess-author", "u1") + author, err := ar.GetCheckpointAuthor(context.Background(), cid) + if err != nil { + t.Fatal(err) + } + // initRepoWithCommit uses testutil.InitRepo, which configures this + // repo-local git identity. + const wantName, wantEmail = "Test User", "test@example.com" + if author.Name != wantName || author.Email != wantEmail { + t.Fatalf("checkpoint commit author = %+v, want Name=%q Email=%q (the repo's configured git identity)", + author, wantName, wantEmail) + } +} + +// TestRun_UnconfiguredGitIdentityFallsBackToDefaults proves that when the +// importer's repo has no configured git user (no local or global user.name / +// user.email), the imported checkpoint commit still gets a signature — the +// same "Unknown"/"unknown@local" default checkpoint.GetGitAuthorFromRepo +// already applies elsewhere, rather than an empty one. +func TestRun_UnconfiguredGitIdentityFallsBackToDefaults(t *testing.T) { + // Cannot use t.Parallel(): isolates HOME via t.Setenv so this repo's + // global git config resolution can't see the developer's real ~/.gitconfig. + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + + repoDir := t.TempDir() + repo, err := git.PlainInit(repoDir, false) + if err != nil { + t.Fatal(err) + } + // Seed one commit with a real timestamp (the anchor resolver's bounded + // walk stops at commits older than its date cutoff; a zero-value When + // would halt it immediately). The commit's own author signature is + // independent of GetGitAuthorFromRepo's config-based resolution under + // test here. + wt, err := repo.Worktree() + if err != nil { + t.Fatal(err) + } + testutil.WriteFile(t, repoDir, "f.txt", "x") + if _, err := wt.Add("f.txt"); err != nil { + t.Fatal(err) + } + if _, err := wt.Commit("init", &git.CommitOptions{ + Author: &object.Signature{Name: "Seed", Email: "seed@test.com", When: time.Now()}, + }); err != nil { + t.Fatal(err) + } + + claudeDir := t.TempDir() + writeFixtureSession(t, claudeDir, "sess-noauthor.jsonl") + + res, err := Run(context.Background(), repo, claudeImporter{}, Options{ + RepoRoot: repoDir, OverridePath: claudeDir, + Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC), + }) + if err != nil { + t.Fatal(err) + } + if res.TurnsImported != 2 { + t.Fatalf("want 2 imported, got %+v", res) + } + + stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{}) + if err != nil { + t.Fatal(err) + } + ar, ok := stores.Persistent.(cp.AuthorReader) + if !ok { + t.Fatalf("persistent store %T does not implement AuthorReader", stores.Persistent) + } + cid := DeriveCheckpointID("sess-noauthor", "u1") + author, err := ar.GetCheckpointAuthor(context.Background(), cid) + if err != nil { + t.Fatal(err) + } + if author.Name != "Unknown" || author.Email != "unknown@local" { + t.Fatalf("checkpoint commit author = %+v, want the GetGitAuthorFromRepo defaults", author) + } +} + func TestRun_DryRunWritesNothing(t *testing.T) { t.Parallel() repo, repoDir := initRepoWithCommit(t)