diff --git a/CHANGELOG.md b/CHANGELOG.md index 035181e..5e129ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Create a missing local database during `init` when the path is inside a Git worktree. Leave an existing database unchanged, and leave a gzip portable source without a raw database file unchanged. Thanks @SebTardif. + ## 0.15.1 - 2026-10-11 **Highlights:** Faster embedding on large archives, reliable local database setup, and Go security fixes. diff --git a/internal/cli/init.go b/internal/cli/init.go index 8c37142..ea079e4 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -2,9 +2,11 @@ package cli import ( "context" + "errors" "flag" "fmt" "io" + "os" "path/filepath" "strings" @@ -118,22 +120,13 @@ func (a *App) runInit(ctx context.Context, args []string) error { if err := config.Save(a.configPath, cfg); err != nil { return err } - if !(cfg.Remote.Enabled() && cfg.Remote.Mode == crawlremote.ModeCloud) { - if err := config.EnsureRuntimeDirs(cfg); err != nil { + if remoteEndpointValue == "" && portableStoreURL == "" { + if err := initializeMissingLocalDatabase(ctx, cfg); err != nil { return err } - } - if remoteEndpointValue == "" && portableStoreURL == "" { - if _, portable, err := portableStoreRoot(ctx, cfg.DBPath); err != nil { + } else if !(cfg.Remote.Enabled() && cfg.Remote.Mode == crawlremote.ModeCloud) { + if err := config.EnsureRuntimeDirs(cfg); err != nil { return err - } else if !portable { - st, err := store.Open(ctx, cfg.DBPath) - if err != nil { - return fmt.Errorf("initialize local database: %w", err) - } - if err := st.Close(); err != nil { - return fmt.Errorf("close initialized local database: %w", err) - } } } result := initResult{ @@ -155,6 +148,53 @@ func (a *App) runInit(ctx context.Context, args []string) error { return a.writeInitOutput(result) } +// initializeMissingLocalDatabase creates the local SQLite file when it is +// absent. An existing file inside a Git worktree is left untouched, as is a +// missing raw file whose manifest selects a gzip archive. Opening either +// would migrate or dirty an unmarked portable source. +func initializeMissingLocalDatabase(ctx context.Context, cfg config.Config) error { + preserve, err := preservePortableDatabase(ctx, cfg.DBPath) + if err != nil { + return err + } + if preserve { + return nil + } + if err := config.EnsureRuntimeDirs(cfg); err != nil { + return err + } + st, err := store.Open(ctx, cfg.DBPath) + if err != nil { + return fmt.Errorf("initialize local database: %w", err) + } + if err := st.Close(); err != nil { + return fmt.Errorf("close initialized local database: %w", err) + } + return nil +} + +func preservePortableDatabase(ctx context.Context, dbPath string) (bool, error) { + _, portable, err := portableStoreRoot(ctx, dbPath) + if err != nil { + return false, err + } + if !portable { + return false, nil + } + _, err = os.Stat(dbPath) + if err == nil { + return true, nil + } + if !errors.Is(err, os.ErrNotExist) { + return false, fmt.Errorf("inspect local database: %w", err) + } + _, _, compressed, err := portableSourceArtifact(dbPath) + if err != nil { + return false, err + } + return compressed, nil +} + func (a *App) absoluteInitPath(path string) (string, error) { originalPath := strings.TrimSpace(path) path = crawlconfig.ExpandHome(path) diff --git a/internal/cli/init_worktree_test.go b/internal/cli/init_worktree_test.go new file mode 100644 index 0000000..71b01f2 --- /dev/null +++ b/internal/cli/init_worktree_test.go @@ -0,0 +1,131 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/openclaw/gitcrawl/internal/store" +) + +func TestInitCreatesMissingDatabaseInsideGitWorktree(t *testing.T) { + ctx := context.Background() + + t.Run("db flag", func(t *testing.T) { + repo := initOrdinaryGitRepo(t, ctx) + dbPath := filepath.Join(repo, "gitcrawl.db") + assertOrdinaryGitWorktree(t, ctx, repo, dbPath) + configPath := filepath.Join(repo, "config.toml") + if err := New().Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil { + t.Fatalf("init: %v", err) + } + assertInitializedLocalDatabase(t, ctx, dbPath) + if err := New().Run(ctx, []string{"--config", configPath, "doctor"}); err != nil { + t.Fatalf("doctor: %v", err) + } + }) + + t.Run("runtime dir", func(t *testing.T) { + repo := initOrdinaryGitRepo(t, ctx) + runtimeDir := filepath.Join(repo, "runtime") + dbPath := filepath.Join(runtimeDir, "gitcrawl.db") + assertOrdinaryGitWorktree(t, ctx, repo, dbPath) + configPath := filepath.Join(repo, "config.toml") + if err := New().Run(ctx, []string{"--config", configPath, "init", "--runtime-dir", runtimeDir}); err != nil { + t.Fatalf("init: %v", err) + } + assertInitializedLocalDatabase(t, ctx, dbPath) + if err := New().Run(ctx, []string{"--config", configPath, "doctor"}); err != nil { + t.Fatalf("doctor: %v", err) + } + }) +} + +func TestInitLeavesExistingWorktreeDatabaseUntouched(t *testing.T) { + ctx := context.Background() + repo := initOrdinaryGitRepo(t, ctx) + dbPath := filepath.Join(repo, "gitcrawl.db") + assertOrdinaryGitWorktree(t, ctx, repo, dbPath) + const sentinel = "not a gitcrawl database\n" + if err := os.WriteFile(dbPath, []byte(sentinel), 0o644); err != nil { + t.Fatalf("write sentinel: %v", err) + } + configPath := filepath.Join(repo, "config.toml") + if err := New().Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil { + t.Fatalf("init: %v", err) + } + got, err := os.ReadFile(dbPath) + if err != nil { + t.Fatalf("read database: %v", err) + } + if string(got) != sentinel { + t.Fatalf("existing worktree database changed to %q", got) + } +} + +func TestInitDBPreservesCompressedPortableCheckout(t *testing.T) { + fixture := newPortableRefreshFixture(t, true) + logical := filepath.Join(fixture.checkout, fixture.relative) + if _, err := os.Stat(logical); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("fixture raw database: %v", err) + } + before := portableTestGit(t, fixture.checkout, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching") + if before != "" { + t.Fatalf("fixture not clean: %q", before) + } + configPath := filepath.Join(filepath.Dir(fixture.checkout), "local.toml") + app := New() + var stdout, stderr bytes.Buffer + app.Stdout, app.Stderr = &stdout, &stderr + if err := app.Run(context.Background(), []string{"--config", configPath, "init", "--db", logical}); err != nil { + t.Fatalf("init: %v\n%s", err, stderr.String()) + } + if _, err := os.Stat(logical); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("init created raw database: %v", err) + } + after := portableTestGit(t, fixture.checkout, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching") + if after != "" { + t.Fatalf("init dirtied compressed checkout: %q", after) + } + result, err := fixture.refresh(t) + if err != nil { + t.Fatalf("refresh: %+v %v", result, err) + } +} + +func initOrdinaryGitRepo(t *testing.T, ctx context.Context) string { + t.Helper() + repo := t.TempDir() + if err := runGit(ctx, repo, "init", "-b", "main"); err != nil { + t.Fatalf("git init: %v", err) + } + return repo +} + +func assertOrdinaryGitWorktree(t *testing.T, ctx context.Context, repo, dbPath string) { + t.Helper() + root, portable, err := portableStoreRoot(ctx, dbPath) + if err != nil { + t.Fatalf("portable store root: %v", err) + } + if !portable || root != repo { + t.Fatalf("portable store root = %q, portable=%v, want ordinary worktree %q", root, portable, repo) + } +} + +func assertInitializedLocalDatabase(t *testing.T, ctx context.Context, dbPath string) { + t.Helper() + st, err := store.OpenReadOnly(ctx, dbPath) + if err != nil { + t.Fatalf("open initialized database: %v", err) + } + if _, err := st.Status(ctx); err != nil { + t.Fatalf("read initialized database: %v", err) + } + if err := st.Close(); err != nil { + t.Fatalf("close initialized database: %v", err) + } +}