diff --git a/cmd/kai/bridge_default_test.go b/cmd/kai/bridge_default_test.go new file mode 100644 index 0000000..2ede990 --- /dev/null +++ b/cmd/kai/bridge_default_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAutoImportEnabled_DefaultTrue(t *testing.T) { + chdirTemp(t) + oldKaiDir := kaiDir + kaiDir = ".kai" + defer func() { kaiDir = oldKaiDir }() + + // No config at all → the git→kai direction defaults on. + if !autoImportEnabled() { + t.Fatal("autoImportEnabled() false with no config; default must be true") + } + + // Explicit opt-out wins. + if err := os.MkdirAll(kaiDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(kaiDir, "config.yaml"), []byte("bridge:\n auto_import: false\n"), 0644); err != nil { + t.Fatal(err) + } + if autoImportEnabled() { + t.Fatal("autoImportEnabled() true despite bridge.auto_import: false") + } +} + +// TestImportHookScripts_RunInBackground pins the default-on latency +// guarantee: every import hook backgrounds its kai invocation so git +// returns immediately. A synchronous `kai bridge import` in a default +// hook would put a capture on every commit's critical path. +func TestImportHookScripts_RunInBackground(t *testing.T) { + for name, s := range map[string]string{ + "postCommit": postCommitHookScript, + "postMerge": postMergeHookScript, + "postCheckout": postCheckoutHookScript, + "postRewrite": postRewriteHookScript, + } { + if !strings.Contains(s, ") &") { + t.Errorf("%s hook runs its import synchronously (no backgrounded subshell)", name) + } + } + // The pre-* hooks must NOT background anything — they exist to gate. + for name, s := range map[string]string{ + "preCommit": preCommitHookScript, + "prePush": prePushHookScript, + } { + if strings.Contains(s, ") &") { + t.Errorf("%s hook backgrounds work; pre-hooks must stay synchronous", name) + } + } +} + +// The post-rewrite hook reads stdin, which git closes when the hook +// process exits — the script must buffer stdin before backgrounding. +func TestPostRewriteHookScript_BuffersStdinBeforeBackgrounding(t *testing.T) { + if !strings.Contains(postRewriteHookScript, "PAIRS=$(cat)") { + t.Fatal("post-rewrite hook must buffer stdin before the backgrounded subshell") + } +} diff --git a/cmd/kai/bridge_test.go b/cmd/kai/bridge_test.go index 7606525..c6964e6 100644 --- a/cmd/kai/bridge_test.go +++ b/cmd/kai/bridge_test.go @@ -71,6 +71,7 @@ func TestHookScripts_AllContainReEntrancyGuard(t *testing.T) { "postCommit": postCommitHookScript, "postMerge": postMergeHookScript, "postCheckout": postCheckoutHookScript, + "postRewrite": postRewriteHookScript, } for name, s := range scripts { if !strings.Contains(s, `"${KAI_BRIDGE_INPROGRESS:-}" = "1"`) { @@ -90,6 +91,7 @@ func TestHookScripts_AllTaggedWithCurrentVersion(t *testing.T) { "postCommit": postCommitHookScript, "postMerge": postMergeHookScript, "postCheckout": postCheckoutHookScript, + "postRewrite": postRewriteHookScript, } { if !strings.Contains(s, tag) { t.Errorf("%s hook missing version tag %q", name, tag) @@ -198,12 +200,17 @@ func setupBridgeRepo(t *testing.T) { } } -func TestBridgeImport_NoOpWhenBridgeDisabled(t *testing.T) { +func TestBridgeImport_NoOpWhenBothDirectionsOff(t *testing.T) { setupBridgeRepo(t) - // Remove sentinel: disabled + // The import runs by default (bridge.auto_import) even without the + // milestone sentinel — turning it off requires BOTH: no sentinel AND + // an explicit auto_import opt-out. if err := os.Remove(filepath.Join(".kai", "bridge-enabled")); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(".kai", "config.yaml"), []byte("bridge:\n auto_import: false\n"), 0644); err != nil { + t.Fatal(err) + } sha := gitHeadSHA(t) if err := runBridgeImport(nil, []string{sha}); err != nil { t.Fatalf("import returned error: %v", err) @@ -215,6 +222,22 @@ func TestBridgeImport_NoOpWhenBridgeDisabled(t *testing.T) { } } +func TestBridgeImport_RunsByDefaultWithoutSentinel(t *testing.T) { + setupBridgeRepo(t) + // No sentinel, no config: auto_import defaults on, so the import + // proceeds — this is what keeps the graph from drifting by default. + if err := os.Remove(filepath.Join(".kai", "bridge-enabled")); err != nil { + t.Fatal(err) + } + sha := gitHeadSHA(t) + if err := runBridgeImport(nil, []string{sha}); err != nil { + t.Fatalf("import returned error: %v", err) + } + if !hasAnyGitRef(t) { + t.Fatal("default auto_import should have imported the commit") + } +} + func TestBridgeImport_CreatesGitRef(t *testing.T) { setupBridgeRepo(t) sha := gitHeadSHA(t) @@ -310,9 +333,10 @@ func TestBridgeImport_HandlesUnknownSHA(t *testing.T) { } } -func TestHookInstall_SkipsPostCommitWhenBridgeDisabled(t *testing.T) { +func TestHookInstall_InstallsImportHooksByDefault(t *testing.T) { chdirTemp(t) - // git repo present, but no bridge sentinel. + // git repo present, no bridge sentinel, no config: the git→kai import + // hooks install by default. if err := os.MkdirAll(filepath.Join(".git", "hooks"), 0755); err != nil { t.Fatal(err) } @@ -321,8 +345,31 @@ func TestHookInstall_SkipsPostCommitWhenBridgeDisabled(t *testing.T) { if err := runHookInstall(nil, nil); err != nil { t.Fatalf("hook install: %v", err) } + for _, name := range []string{"pre-commit", "post-commit", "post-merge", "post-checkout", "post-rewrite"} { + if _, err := os.Stat(filepath.Join(".git", "hooks", name)); err != nil { + t.Errorf("%s hook was not installed by default", name) + } + } +} + +func TestHookInstall_SkipsImportHooksWhenAutoImportOff(t *testing.T) { + chdirTemp(t) + if err := os.MkdirAll(filepath.Join(".git", "hooks"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(".kai", 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(".kai", "config.yaml"), []byte("bridge:\n auto_import: false\n"), 0644); err != nil { + t.Fatal(err) + } + initMode = true + defer func() { initMode = false }() + if err := runHookInstall(nil, nil); err != nil { + t.Fatalf("hook install: %v", err) + } if _, err := os.Stat(filepath.Join(".git", "hooks", "post-commit")); err == nil { - t.Fatal("post-commit hook was installed even though bridge is disabled") + t.Fatal("post-commit hook installed despite bridge.auto_import: false") } // pre-commit should still be installed. if _, err := os.Stat(filepath.Join(".git", "hooks", "pre-commit")); err != nil { diff --git a/cmd/kai/commands_test.go b/cmd/kai/commands_test.go index a9f6dc7..2597451 100644 --- a/cmd/kai/commands_test.go +++ b/cmd/kai/commands_test.go @@ -371,9 +371,9 @@ func TestShortID(t *testing.T) { // TestCommandFlags tests that commands have expected flags func TestCommandFlags(t *testing.T) { tests := []struct { - cmd *cobra.Command - flags []string - cmdName string + cmd *cobra.Command + flags []string + cmdName string }{ {snapshotCreateCmd, []string{"repo", "dir", "message"}, "snapshot create"}, {statusCmd, []string{"dir", "against", "name-only", "json", "semantic"}, "status"}, @@ -404,6 +404,12 @@ func TestRunInit_CreatesKaiDirectory(t *testing.T) { os.Chdir(tmpDir) defer os.Chdir(oldWd) + // Local graph only — a logged-in developer's suite run must never + // sign up repos on kaicontext.com. + oldNoRemote := initNoRemote + initNoRemote = true + defer func() { initNoRemote = oldNoRemote }() + // Run init err := runInit(initCmd, nil) if err != nil { @@ -449,6 +455,10 @@ func TestRunInit_Idempotent(t *testing.T) { os.Chdir(tmpDir) defer os.Chdir(oldWd) + oldNoRemote := initNoRemote + initNoRemote = true + defer func() { initNoRemote = oldNoRemote }() + // Run init twice if err := runInit(initCmd, nil); err != nil { t.Fatalf("first runInit failed: %v", err) @@ -484,8 +494,8 @@ func TestCommandHelp(t *testing.T) { func TestSubcommandArgs(t *testing.T) { // Commands that require exact args exactArgsCommands := []struct { - cmd *cobra.Command - args int + cmd *cobra.Command + args int }{ {analyzeSymbolsCmd, 1}, {changesetCreateCmd, 2}, @@ -732,11 +742,11 @@ func TestFlagsHaveDefaults(t *testing.T) { // TestShowUnifiedDiff tests the pure Go unified diff implementation func TestShowUnifiedDiff(t *testing.T) { tests := []struct { - name string - before string - after string - wantAdd bool - wantDel bool + name string + before string + after string + wantAdd bool + wantDel bool }{ { name: "single line addition", diff --git a/cmd/kai/init_test.go b/cmd/kai/init_test.go index 1d5ecae..e402b5b 100644 --- a/cmd/kai/init_test.go +++ b/cmd/kai/init_test.go @@ -60,6 +60,10 @@ func TestRunInitAlreadyInitialized(t *testing.T) { initForce = false defer func() { initForce = oldInitForce }() + oldNoRemote := initNoRemote + initNoRemote = true + defer func() { initNoRemote = oldNoRemote }() + r, w, err := os.Pipe() if err != nil { t.Fatalf("os.Pipe: %v", err) diff --git a/cmd/kai/main.go b/cmd/kai/main.go index 224aa77..8355a37 100644 --- a/cmd/kai/main.go +++ b/cmd/kai/main.go @@ -2798,7 +2798,7 @@ out-of-date kai-managed git hooks).`, } const kaiHookMarker = "# kai-managed-hook" -const kaiHookVersion = "v5" +const kaiHookVersion = "v6" // All hook scripts early-exit when KAI_BRIDGE_INPROGRESS=1. Set by kai itself // when it is the one driving the git operation (e.g. milestone→commit bridge), @@ -2859,13 +2859,16 @@ fi exit 0 ` -// postCommitHookScript imports each new git commit as a kai snapshot. Only -// installed when the kai↔git bridge is enabled (kai init --git-bridge). -// Idempotent via content-addressing; skips commits that carry a Kai-Snapshot: -// trailer (they came from kai's own milestone→commit path). +// postCommitHookScript imports each new git commit as a kai snapshot. +// Installed by default (git→kai direction; opt out with +// `bridge.auto_import: false`). Idempotent via content-addressing; skips +// commits that carry a Kai-Snapshot: trailer (they came from kai's own +// milestone→commit path). The import runs in the background so it adds +// nothing to `git commit` latency — a lost import self-heals via the watch +// daemon or inline catch-up. const postCommitHookScript = `#!/bin/sh ` + kaiHookMarker + ` ` + kaiHookVersion + ` -# Auto-installed by 'kai init --git-bridge'. +# Auto-installed by 'kai init'. # Best-effort: never blocks git. Remove with: kai hook uninstall if [ "${KAI_BRIDGE_INPROGRESS:-}" = "1" ]; then @@ -2878,16 +2881,17 @@ if [ ! -d .git/kai ] && [ ! -d .kai ]; then exit 0 fi SHA=$(git rev-parse HEAD 2>/dev/null) || exit 0 -kai bridge import "$SHA" >/dev/null 2>&1 || true +( kai bridge import "$SHA" >/dev/null 2>&1 || true ) & exit 0 ` // postMergeHookScript imports any new commits brought in by a merge or -// pull (fast-forward or otherwise). Only installed when the bridge is -// enabled. Uses ORIG_HEAD..HEAD to walk just the new commits. +// pull (fast-forward or otherwise). Installed by default (git→kai +// direction). Uses ORIG_HEAD..HEAD to walk just the new commits, serially +// but in a background subshell so git returns immediately. const postMergeHookScript = `#!/bin/sh ` + kaiHookMarker + ` ` + kaiHookVersion + ` -# Auto-installed by 'kai init --git-bridge'. +# Auto-installed by 'kai init'. # Best-effort: never blocks git. Remove with: kai hook uninstall if [ "${KAI_BRIDGE_INPROGRESS:-}" = "1" ]; then @@ -2901,18 +2905,19 @@ if [ ! -d .git/kai ] && [ ! -d .kai ]; then fi # Walk new commits (oldest first) and import each. ORIG_HEAD is set by # merge/pull to the old HEAD. -git rev-list --reverse ORIG_HEAD..HEAD 2>/dev/null | while read -r SHA; do +( git rev-list --reverse ORIG_HEAD..HEAD 2>/dev/null | while read -r SHA; do kai bridge import "$SHA" >/dev/null 2>&1 || true -done +done ) & exit 0 ` // postCheckoutHookScript imports any new commits after a branch switch -// that brings in history the working copy hadn't seen. $1 = previous HEAD, -// $2 = new HEAD, $3 = 1 when switching branches (0 for file checkout). +// that brings in history the working copy hadn't seen. Installed by +// default (git→kai direction). $1 = previous HEAD, $2 = new HEAD, +// $3 = 1 when switching branches (0 for file checkout). const postCheckoutHookScript = `#!/bin/sh ` + kaiHookMarker + ` ` + kaiHookVersion + ` -# Auto-installed by 'kai init --git-bridge'. +# Auto-installed by 'kai init'. # Best-effort: never blocks git. Remove with: kai hook uninstall if [ "${KAI_BRIDGE_INPROGRESS:-}" = "1" ]; then @@ -2933,9 +2938,9 @@ NEW="$2" if [ "$PREV" = "$NEW" ]; then exit 0 fi -git rev-list --reverse "$PREV".."$NEW" 2>/dev/null | while read -r SHA; do +( git rev-list --reverse "$PREV".."$NEW" 2>/dev/null | while read -r SHA; do kai bridge import "$SHA" >/dev/null 2>&1 || true -done +done ) & exit 0 ` @@ -2944,10 +2949,10 @@ exit 0 // each new commit is imported (idempotent), and the bridge's rewrite // detection links the superseded line (F-16) so the graph never silently // forks. Without this hook a rebase leaves the graph pinned to orphaned -// SHAs until the next commit. Only installed when the bridge is enabled. +// SHAs until the next commit. Installed by default (git→kai direction). const postRewriteHookScript = `#!/bin/sh ` + kaiHookMarker + ` ` + kaiHookVersion + ` -# Auto-installed by 'kai init --git-bridge'. +# Auto-installed by 'kai init'. # Best-effort: never blocks git. Remove with: kai hook uninstall if [ "${KAI_BRIDGE_INPROGRESS:-}" = "1" ]; then @@ -2960,10 +2965,13 @@ if [ ! -d .git/kai ] && [ ! -d .kai ]; then exit 0 fi # stdin: one " [extra]" line per rewritten commit. -while read -r OLD NEW REST; do +# Buffer stdin before backgrounding — git closes the pipe when the hook +# exits, so the subshell must not read from it after that. +PAIRS=$(cat) +( printf '%s\n' "$PAIRS" | while read -r OLD NEW REST; do [ -n "$NEW" ] || continue kai bridge import "$NEW" >/dev/null 2>&1 || true -done +done ) & exit 0 ` @@ -3004,7 +3012,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { fmt.Printf("%s git repository detected\n", ok) checkHook("pre-commit", preCommitHookScript) checkHook("pre-push", prePushHookScript) - if bridgeEnabled() { + if bridgeEnabled() || autoImportEnabled() { checkHook("post-commit", postCommitHookScript) checkHook("post-merge", postMergeHookScript) checkHook("post-checkout", postCheckoutHookScript) @@ -3205,7 +3213,7 @@ func selfHealHooks() { } upgradeIfOldKaiHook(filepath.Join(".git", "hooks", "pre-commit"), preCommitHookScript) upgradeIfOldKaiHook(filepath.Join(".git", "hooks", "pre-push"), prePushHookScript) - if bridgeEnabled() { + if bridgeEnabled() || autoImportEnabled() { upgradeIfOldKaiHook(filepath.Join(".git", "hooks", "post-commit"), postCommitHookScript) upgradeIfOldKaiHook(filepath.Join(".git", "hooks", "post-merge"), postMergeHookScript) upgradeIfOldKaiHook(filepath.Join(".git", "hooks", "post-checkout"), postCheckoutHookScript) @@ -3216,6 +3224,19 @@ func selfHealHooks() { // bridgeEnabled reports whether the kai↔git bridge is turned on for this // repo. Presence of /bridge-enabled is the sentinel; the file is // written by 'kai init --git-bridge' (or 'kai bridge enable', future). +// autoImportEnabled reports whether the git→kai import direction is on: +// the post-commit/merge/checkout/rewrite hooks importing commits as +// snapshots. Default true (config `bridge.auto_import`) — this direction +// never writes to git and is what keeps the graph from drifting. Distinct +// from bridgeEnabled(), which gates the kai→git milestone direction. +func autoImportEnabled() bool { + cfg, err := config.Load(kaiDir) + if err != nil { + return true // a malformed config must not silently stop drift healing + } + return cfg.Bridge.AutoImport +} + func bridgeEnabled() bool { _, err := os.Stat(filepath.Join(kaiDir, "bridge-enabled")) return err == nil @@ -3401,8 +3422,8 @@ func gitHistoryRewritten(dir, prevSHA, newSHA string) bool { } func runBridgeImport(cmd *cobra.Command, args []string) error { - if !bridgeEnabled() { - // Hook may have been installed manually; be a no-op instead of loud. + if !bridgeEnabled() && !autoImportEnabled() { + // Hook present but both directions off; be a no-op instead of loud. return nil } sha := strings.TrimSpace(args[0]) @@ -3636,8 +3657,9 @@ func runHookInstall(cmd *cobra.Command, args []string) error { } } - // Bridge hooks — only when the kai↔git bridge is enabled for this repo. - if bridgeEnabled() { + // Import hooks — on by default (bridge.auto_import); the git→kai + // direction only. They also serve the opt-in milestone bridge. + if bridgeEnabled() || autoImportEnabled() { installOrUpgradeBridgeHook("post-commit", postCommitHookScript) installOrUpgradeBridgeHook("post-merge", postMergeHookScript) installOrUpgradeBridgeHook("post-checkout", postCheckoutHookScript) @@ -4101,7 +4123,7 @@ func init() { initCmd.GroupID = groupStart captureCmd.GroupID = groupStart initCmd.Flags().BoolVar(&initExplain, "explain", false, "Show detailed explanation of what this command does") - initCmd.Flags().BoolVar(&initGitBridge, "git-bridge", false, "Enable the kai↔git bridge (installs post-commit hook; kai milestones become git commits)") + initCmd.Flags().BoolVar(&initGitBridge, "git-bridge", false, "Enable the kai→git direction of the bridge: kai milestones become git commits (the git→kai import hooks are installed by default; opt out with bridge.auto_import: false)") initCmd.Flags().BoolVar(&initForce, "force", false, "Re-run initialization even if kai is already set up in this directory") initCmd.Flags().BoolVar(&initAssumeYes, "yes", false, "Non-interactive: auto-select the personal org and link an existing repo (also implied when stdin isn't a TTY)") initCmd.Flags().StringVar(&initOrg, "org", "", "Org slug to initialize under (default: your personal org; also via KAI_ORG)") diff --git a/internal/config/config.go b/internal/config/config.go index 5fa6712..66ea33e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,18 @@ type Config struct { Autonomy string `yaml:"autonomy"` Staleness StalenessConfig `yaml:"staleness"` + Bridge BridgeConfig `yaml:"bridge"` +} + +// BridgeConfig controls the kai↔git bridge directions independently. +type BridgeConfig struct { + // AutoImport gates the git→kai direction: the post-commit/merge/ + // checkout/rewrite hooks importing commits as snapshots. Default true + // (via Default()) — this direction never writes to git and is what + // keeps the graph from drifting. Set `bridge.auto_import: false` to + // opt out. The kai→git direction (milestones become commits) is + // separate and stays opt-in via `kai init --git-bridge`. + AutoImport bool `yaml:"auto_import"` } // StalenessConfig controls how query commands react to graph↔git drift. @@ -240,6 +252,9 @@ func Default() Config { Staleness: StalenessConfig{ InlineBudgetMS: 2000, }, + Bridge: BridgeConfig{ + AutoImport: true, + }, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a827d56..52bc7db 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -54,6 +54,7 @@ planner: Enabled: true, // yaml didn't set it → default applies }, Staleness: Default().Staleness, // yaml didn't set it → default applies + Bridge: Default().Bridge, // yaml didn't set it → default applies } if !reflect.DeepEqual(cfg, want) { t.Fatalf("unexpected config:\n got: %+v\nwant: %+v", cfg, want)