diff --git a/cmd/kai/init_no_history_test.go b/cmd/kai/init_no_history_test.go new file mode 100644 index 0000000..212d2c7 --- /dev/null +++ b/cmd/kai/init_no_history_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/kaicontext/kai-engine/graph" +) + +// initTestRepo builds a throwaway git repo with n commits and chdirs into it. +func initTestRepo(t *testing.T, commits int) string { + t.Helper() + dir := t.TempDir() + + run := func(args ...string) { + t.Helper() + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v: %v\n%s", args, err, out) + } + } + run("git", "init", "--quiet") + run("git", "config", "user.email", "test@example.com") + run("git", "config", "user.name", "Test") + for i := 0; i < commits; i++ { + name := filepath.Join(dir, "f"+string(rune('a'+i))+".go") + if err := os.WriteFile(name, []byte("package main\n\nfunc F"+string(rune('a'+i))+"() {}\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + run("git", "add", "-A") + run("git", "commit", "--quiet", "-m", "commit") + } + + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { os.Chdir(wd) }) + return dir +} + +// snapshotCount reports how many Snapshot nodes the repo's graph holds. +func snapshotCount(t *testing.T, dir string) int { + t.Helper() + db, err := graph.Open(filepath.Join(dir, kaiDir, dbFile), filepath.Join(dir, kaiDir, objectsDir)) + if err != nil { + t.Fatalf("open graph: %v", err) + } + defer db.Close() + nodes, err := db.GetNodesByKind(graph.KindSnapshot) + if err != nil { + t.Fatalf("nodes by kind: %v", err) + } + return len(nodes) +} + +// runInitInRepo runs a local-only init in the current directory. +func runInitInRepo(t *testing.T, noHistory bool) { + t.Helper() + oldKaiDir, oldRemote, oldYes, oldHist := kaiDir, initNoRemote, initAssumeYes, initNoHistory + kaiDir = ".kai" + initNoRemote, initAssumeYes, initNoHistory = true, true, noHistory + t.Cleanup(func() { + kaiDir, initNoRemote, initAssumeYes, initNoHistory = oldKaiDir, oldRemote, oldYes, oldHist + }) + if err := runInit(nil, nil); err != nil { + t.Fatalf("runInit(noHistory=%v): %v", noHistory, err) + } +} + +// The history import is nearly all of init's wall clock and a review pod never +// reads what it produces, so --no-history must actually skip it — and must +// still leave the working tree's own graph behind, which is the thing the +// review does read. +func TestInitNoHistorySkipsTheImport(t *testing.T) { + dir := initTestRepo(t, 3) + runInitInRepo(t, true) + + if got := snapshotCount(t, dir); got != 1 { + t.Fatalf("--no-history should leave only the working-tree capture, got %d snapshots", got) + } +} + +// The default is unchanged: a person running `kai init` still gets the history +// that makes log, blame and bisect worth having. +func TestInitImportsHistoryByDefault(t *testing.T) { + dir := initTestRepo(t, 3) + runInitInRepo(t, false) + + if got := snapshotCount(t, dir); got <= 1 { + t.Fatalf("default init should import history, got %d snapshots", got) + } +} diff --git a/cmd/kai/main.go b/cmd/kai/main.go index c920985..7b978e7 100644 --- a/cmd/kai/main.go +++ b/cmd/kai/main.go @@ -4525,6 +4525,7 @@ func init() { initCmd.Flags().StringVar(&initOrg, "org", "", "Org slug to initialize under (default: your personal org; also via KAI_ORG)") initCmd.Flags().StringVar(&initEmail, "email", "", "Email to sign up / log in with non-interactively (also via KAI_INIT_EMAIL)") initCmd.Flags().BoolVar(&initNoRemote, "no-remote", false, "Build the local semantic graph only: skip kaicontext.com signup/login and the automatic push") + initCmd.Flags().BoolVar(&initNoHistory, "no-history", false, "Skip the git-history import: build the graph for the working tree only. For one-shot checkouts (CI review pods, ephemeral workspaces) that read the head graph and never query past snapshots") rootCmd.AddCommand(initCmd) rootCmd.AddCommand(captureCmd) @@ -4891,6 +4892,13 @@ var initEmail string // 2026-08-23). var initNoRemote bool +// initNoHistory: skip replaying git history as snapshots. The import is the +// bulk of `kai init` on any real repo — 50 commits, each a full snapshot — and +// a one-shot checkout never reads them: the review's blast walk uses the +// freshly-captured single-snapshot graph, and base...head comes from git. +// Measured on kai-server at ba7e4c2: 83.7s with the import, 4.1s without. +var initNoHistory bool + // initOrgOverride returns an explicit org slug from --org or KAI_ORG, else "". func initOrgOverride() string { if strings.TrimSpace(initOrg) != "" { @@ -5852,11 +5860,18 @@ CREATE INDEX IF NOT EXISTS authorship_file ON authorship_ranges(snapshot_id, fil // Auto-import git history. runGitImport caps at importMaxCommits (default 50) // so large repos only import the most recent slice. - if countOut, err := exec.Command("git", "rev-list", "--count", "HEAD").Output(); err == nil { - if count, _ := strconv.Atoi(strings.TrimSpace(string(countOut))); count > 0 { - stop := spinner("Importing git history") - importErr := runGitImport(db) - stop(importErr) + // + // --no-history skips it outright. Those snapshots are what make `kai + // log`, blame and bisect useful to a person, and they cost nearly all of + // init's wall clock; a checkout that is deleted at the end of one review + // pays that price for history nothing will ever read. + if !initNoHistory { + if countOut, err := exec.Command("git", "rev-list", "--count", "HEAD").Output(); err == nil { + if count, _ := strconv.Atoi(strings.TrimSpace(string(countOut))); count > 0 { + stop := spinner("Importing git history") + importErr := runGitImport(db) + stop(importErr) + } } }