Skip to content
Closed
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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help build test bundle test-gate docker-build-cuda clean
.PHONY: help build test bundle test-gate docker-build-cuda docker-build-cuda-dev clean

help build test bundle test-gate docker-build-cuda clean:
help build test bundle test-gate docker-build-cuda docker-build-cuda-dev clean:
@$(MAKE) -C server $@
68 changes: 68 additions & 0 deletions cli/cmd/cancel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package cmd

import (
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"
)

var cancelProject string

var cancelCmd = &cobra.Command{
Use: "cancel",
Short: "Cancel an active indexing session",
Long: `Cancel any in-flight indexing session for a project.

Useful when a previous 'cix reindex' was interrupted by a network issue or
client-side timeout but the server is still holding a session lock and
returning 409 Conflict on subsequent /index/begin attempts.

Idempotent: succeeds (no-op) when no session is active.

Examples:
cix cancel
cix cancel -p /path/to/project`,
RunE: runCancel,
}

func init() {
rootCmd.AddCommand(cancelCmd)
cancelCmd.Flags().StringVarP(&cancelProject, "project", "p", "", "Project path (default: current directory)")
}

func runCancel(cmd *cobra.Command, args []string) error {
projectPath := cancelProject
if projectPath == "" {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("get working directory: %w", err)
}
projectPath = cwd
}

absPath, err := filepath.Abs(projectPath)
if err != nil {
return fmt.Errorf("resolve path: %w", err)
}

apiClient, err := getClient()
if err != nil {
return err
}

absPath = findProjectRoot(absPath, apiClient)

resp, err := apiClient.CancelIndex(absPath)
if err != nil {
return fmt.Errorf("cancel: %w", err)
}

if resp.Cancelled {
fmt.Printf("✓ Cancelled active indexing session for %s\n", absPath)
} else {
fmt.Printf("No active session for %s (nothing to cancel)\n", absPath)
}
return nil
}
100 changes: 100 additions & 0 deletions cli/cmd/cancel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package cmd

import (
"net/http"
"strings"
"testing"
)

func TestRunCancel_ActiveSession(t *testing.T) {
proj := t.TempDir()
hash := projectHash(proj)

srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/api/v1/projects"):
writeJSON(w, 200, map[string]any{"projects": []any{}, "total": 0})
case strings.Contains(r.URL.Path, hash+"/index/cancel") && r.Method == http.MethodPost:
writeJSON(w, 200, map[string]any{"cancelled": true})
default:
http.NotFound(w, r)
}
})
useAPI(t, srv)

old := cancelProject
defer func() { cancelProject = old }()
cancelProject = proj

out, err := captureOutput(func() error {
return runCancel(nil, nil)
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(out, "Cancelled active indexing session") {
t.Errorf("expected success message, got:\n%s", out)
}
}

func TestRunCancel_NoActiveSession(t *testing.T) {
proj := t.TempDir()
hash := projectHash(proj)

srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/api/v1/projects"):
writeJSON(w, 200, map[string]any{"projects": []any{}, "total": 0})
case strings.Contains(r.URL.Path, hash+"/index/cancel"):
writeJSON(w, 200, map[string]any{"cancelled": false})
default:
http.NotFound(w, r)
}
})
useAPI(t, srv)

old := cancelProject
defer func() { cancelProject = old }()
cancelProject = proj

out, err := captureOutput(func() error {
return runCancel(nil, nil)
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(out, "No active session") {
t.Errorf("expected idempotent message, got:\n%s", out)
}
}

func TestRunCancel_APIError(t *testing.T) {
proj := t.TempDir()
hash := projectHash(proj)

srv := mockServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/api/v1/projects"):
writeJSON(w, 200, map[string]any{"projects": []any{}, "total": 0})
case strings.Contains(r.URL.Path, hash+"/index/cancel"):
apiError(w, 500, "internal error")
default:
http.NotFound(w, r)
}
})
useAPI(t, srv)

old := cancelProject
defer func() { cancelProject = old }()
cancelProject = proj

_, err := captureOutput(func() error {
return runCancel(nil, nil)
})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "cancel") {
t.Errorf("expected 'cancel' in error, got: %v", err)
}
}
2 changes: 1 addition & 1 deletion cli/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func runInit(cmd *cobra.Command, args []string) error {
cfg, _ := config.Load()
batchSize := cfg.Indexing.BatchSize
fmt.Printf("Starting indexing (batch size: %d)...\n", batchSize)
result, err := indexer.Run(client, absPath, false, batchSize)
result, err := indexer.Run(cmd.Context(), client, absPath, false, batchSize, indexer.AutoProgressMode())
if err != nil {
return fmt.Errorf("indexing failed: %w", err)
}
Expand Down
17 changes: 16 additions & 1 deletion cli/cmd/reindex.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package cmd

import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"

"github.com/anthropics/code-index/cli/internal/config"
Expand Down Expand Up @@ -68,8 +71,20 @@ func runReindex(cmd *cobra.Command, args []string) error {

fmt.Printf("%s reindexing: %s (batch size: %d)\n", indexType, absPath, batchSize)

result, err := indexer.Run(apiClient, absPath, reindexFull, batchSize)
// SIGINT/SIGTERM → ctx cancellation. The indexer propagates ctx through
// SendFilesStreaming, which closes the HTTP connection; the server's
// streaming handler sees the disconnect and calls CancelIndexing,
// freeing the project lock immediately rather than at the 1-hour TTL.
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

result, err := indexer.Run(ctx, apiClient, absPath, reindexFull, batchSize, indexer.AutoProgressMode())
if err != nil {
// If the user hit Ctrl+C, surface a friendlier message — the deferred
// CancelIndex inside indexer.Run already freed the server lock.
if ctx.Err() == context.Canceled {
return fmt.Errorf("indexing cancelled by user")
}
return fmt.Errorf("indexing failed: %w", err)
}

Expand Down
7 changes: 6 additions & 1 deletion cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strings"
"time"

"github.com/anthropics/code-index/cli/internal/client"
"github.com/anthropics/code-index/cli/internal/config"
Expand Down Expand Up @@ -126,5 +127,9 @@ func getClient() (*client.Client, error) {
}
}

return client.New(url, key), nil
c := client.New(url, key)
if cfg.Indexing.StreamingIdleTimeoutSec > 0 {
c.SetStreamingIdleTimeout(time.Duration(cfg.Indexing.StreamingIdleTimeoutSec) * time.Second)
}
return c, nil
}
78 changes: 53 additions & 25 deletions cli/cmd/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,34 +112,62 @@ func runSearch(cmd *cobra.Command, args []string) error {
return nil
}

// Print results
fmt.Printf("Found %d result(s) (%.1fms):\n\n", results.Total, results.QueryTimeMS)

for i, result := range results.Results {
// Format score as colored
scoreStr := fmt.Sprintf("%.2f", result.Score)

// Print result header
fmt.Printf("%d. [%s] %s:%d-%d\n",
i+1, scoreStr, result.FilePath, result.StartLine, result.EndLine)

// Print metadata
meta := []string{}
if result.SymbolName != "" {
meta = append(meta, fmt.Sprintf("Symbol: %s", result.SymbolName))
// Files-as-results: --limit is a count of files. Inside each file,
// every match above min_score is shown, ordered by line number so the
// reader walks the file top-to-bottom.
fmt.Printf("Found %d file(s) (%.1fms):\n\n", results.Total, results.QueryTimeMS)

for i, file := range results.Results {
// File header. Best score is the rank driver; total match count
// gives a sense of how relevant this file is overall.
matchWord := "match"
if len(file.Matches) != 1 {
matchWord = "matches"
}
meta = append(meta, fmt.Sprintf("Type: %s", result.ChunkType))
if result.Language != "" {
meta = append(meta, fmt.Sprintf("Lang: %s", result.Language))
langSuffix := ""
if file.Language != "" {
langSuffix = " · " + file.Language
}
fmt.Printf(" %s\n", strings.Join(meta, " | "))

fmt.Printf(" ```%s\n", result.Language)
content := result.Content
for _, line := range strings.Split(content, "\n") {
fmt.Printf(" %s\n", line)
fmt.Printf("%d. %s [best %.2f] %d %s%s\n",
i+1, file.FilePath, file.BestScore, len(file.Matches), matchWord, langSuffix)

for _, m := range file.Matches {
// Per-match separator with score + line range + label so the
// user can scan vertically by relevance, even though matches
// are in line order.
label := m.ChunkType
if m.SymbolName != "" {
label = fmt.Sprintf("%s %s", m.ChunkType, m.SymbolName)
}
rangeStr := fmt.Sprintf("line %d", m.StartLine)
if m.EndLine != m.StartLine {
rangeStr = fmt.Sprintf("lines %d-%d", m.StartLine, m.EndLine)
}
fmt.Printf(" -- [%.2f] %s (%s)\n", m.Score, rangeStr, label)

lang := file.Language
fmt.Printf(" ```%s\n", lang)
for _, line := range strings.Split(m.Content, "\n") {
fmt.Printf(" %s\n", line)
}
fmt.Printf(" ```\n")

// Nested hits — chunks merged INTO this match by the server.
// They sit textually inside m.Content; this just exposes the
// inner anchor points so the user can jump to the exact line.
if len(m.NestedHits) > 0 {
fmt.Printf(" + %d more match(es) inside:\n", len(m.NestedHits))
for _, nh := range m.NestedHits {
nhLabel := nh.ChunkType
if nh.SymbolName != "" {
nhLabel = fmt.Sprintf("%s %s", nh.ChunkType, nh.SymbolName)
}
fmt.Printf(" · [%.2f] line %d (%s)\n",
nh.Score, nh.StartLine, nhLabel)
}
}
}
fmt.Printf(" ```\n\n")
fmt.Println()
}

return nil
Expand Down
25 changes: 15 additions & 10 deletions cli/cmd/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,19 @@ func TestRunSearch_Results(t *testing.T) {
writeJSON(w, 200, map[string]any{
"results": []map[string]any{
{
"file_path": proj + "/api/auth.go",
"start_line": 10,
"end_line": 25,
"content": "func AuthMiddleware() {}",
"score": 0.92,
"chunk_type": "function",
"symbol_name": "AuthMiddleware",
"language": "go",
"file_path": proj + "/api/auth.go",
"language": "go",
"best_score": 0.92,
"matches": []map[string]any{
{
"start_line": 10,
"end_line": 25,
"content": "func AuthMiddleware() {}",
"score": 0.92,
"chunk_type": "function",
"symbol_name": "AuthMiddleware",
},
},
},
},
"total": 1,
Expand Down Expand Up @@ -58,8 +63,8 @@ func TestRunSearch_Results(t *testing.T) {
if !strings.Contains(out, "auth.go") {
t.Errorf("expected file path in output, got:\n%s", out)
}
if !strings.Contains(out, "1 result") {
t.Errorf("expected result count in output, got:\n%s", out)
if !strings.Contains(out, "1 file") {
t.Errorf("expected file count in output, got:\n%s", out)
}
}

Expand Down
Loading
Loading