Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 53 additions & 13 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package cli

import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"

Expand Down Expand Up @@ -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{
Expand All @@ -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)
Expand Down
131 changes: 131 additions & 0 deletions internal/cli/init_worktree_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading