Add <!-- markdownlint-disable MD013 --> comment at the top to prevent vim from loading diagnostics for this file, matching the pattern used in other documentation files.
26 KiB
Implementation Plan: Docs Search API
Overview
Add backend full-text search for 152 documentation files using a new /api/docs/search endpoint, replacing the planned build-time Lunr approach with runtime server-side search.
Type: Full-stack task (backend API + frontend changes)
Status: Planned
Priority: Medium
Estimated effort: 3-4 hours
Table of Contents
- Architecture Decision
- Backend Implementation
- Frontend Changes
- Testing Strategy
- Dependencies to Remove
- Documentation Updates
- Implementation Checklist
Architecture Decision
Why Backend API Instead of Build-time Lunr?
| Aspect | Build-time Lunr (Original Plan) | Backend API (This Plan) |
|---|---|---|
| Build step | ✅ Required | ❌ None |
| Network request | ❌ Client-side only | ✅ API call |
| Server load | ❌ None | ✅ Minimal (152 docs) |
| Offline support | ✅ Yes | ❌ No |
| Search speed | ⚡ Instant | 🌐 Network latency |
| Bundle size | +50KB | No change |
| Always current | ❌ Need rebuild | ✅ Yes |
| Consistency | New pattern | Matches existing /api/media-items/search |
Decision: Backend API is simpler and more consistent with existing architecture.
Backend Implementation
1. Add Search Method to DocsHandler
File: internal/docs/search.go (new file)
package docs
import (
"bufio"
"fmt"
"io/fs"
"path/filepath"
"strings"
"unicode"
)
// SearchResult represents a single search result
type SearchResult struct {
Path string `json:"path"`
Title string `json:"title"`
Section string `json:"section"`
Snippet string `json:"snippet,omitempty"`
}
// SearchDocuments performs full-text search across all markdown files
func (h *DocsHandler) SearchDocuments(query string) ([]SearchResult, error) {
// Validate query
query = strings.TrimSpace(query)
if len(query) < 2 {
return []SearchResult{}, nil
}
// Convert to lowercase for case-insensitive search
queryLower := strings.ToLower(query)
var results []SearchResult
// Walk through all markdown files
err := fs.WalkDir(h.docsFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip directories and non-markdown files
if d.IsDir() || !strings.HasSuffix(path, ".md") {
return nil
}
// Read file content
content, err := fs.ReadFile(h.docsFS, path)
if err != nil {
// Log but continue - don't fail entire search
fmt.Printf("Warning: failed to read %s: %v\n", path, err)
return nil
}
// Extract title and check for matches
title := h.extractTitle(content)
section := h.getSectionFromPath(path)
textContent := h.markdownToText(content)
// Check if query matches
if h.matchesQuery(textContent, queryLower) {
result := SearchResult{
Path: path,
Title: title,
Section: section,
}
// Generate snippet with highlighted match
snippet := h.extractSnippet(textContent, queryLower)
if snippet != "" {
result.Snippet = snippet
}
results = append(results, result)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk docs directory: %w", err)
}
return results, nil
}
// getSectionFromPath derives section name from file path
func (h *DocsHandler) getSectionFromPath(path string) string {
// Remove .md extension
path = strings.TrimSuffix(path, ".md")
// Split by directory separators
parts := strings.Split(path, string(filepath.Separator))
// Filter out empty strings and index.md
var filtered []string
for _, part := range parts {
if part != "" && part != "index.md" && part != "index" {
// Convert kebab-case to Title Case
filtered = append(filtered, kebabToTitle(part))
}
}
// Join with " > "
return strings.Join(filtered, " > ")
}
// kebabToTitle converts kebab-case to Title Case
func kebabToTitle(s string) string {
words := strings.Split(s, "-")
for i, word := range words {
if len(word) > 0 {
// Capitalize first letter
runes := []rune(word)
runes[0] = unicode.ToUpper(runes[0])
words[i] = string(runes)
}
}
return strings.Join(words, " ")
}
// markdownToText converts markdown content to plain text for searching
func (h *DocsHandler) markdownToText(content []byte) string {
// Remove markdown syntax but keep text
lines := strings.Split(string(content), "\n")
var textLines []string
for _, line := range lines {
line = strings.TrimSpace(line)
// Skip code blocks
if strings.HasPrefix(line, "```") {
continue
}
// Remove markdown headers
line = strings.TrimLeft(line, "#")
// Remove bold/italic markers
line = strings.ReplaceAll(line, "**", "")
line = strings.ReplaceAll(line, "*", "")
line = strings.ReplaceAll(line, "__", "")
line = strings.ReplaceAll(line, "_", "")
// Remove links but keep text
line = h.removeMarkdownLinks(line)
// Remove code inline
line = strings.ReplaceAll(line, "`", "")
if line != "" {
textLines = append(textLines, line)
}
}
return strings.Join(textLines, " ")
}
// removeMarkdownLinks removes markdown link syntax
func (h *DocsHandler) removeMarkdownLinks(line string) string {
// Simple approach: remove [text](url) patterns
result := line
for {
start := strings.Index(result, "[")
if start == -1 {
break
}
end := strings.Index(result[start:], "](")
if end == -1 {
break
}
end += start + 2 // include "]("
// Find closing paren
closeParen := strings.Index(result[end:], ")")
if closeParen == -1 {
break
}
closeParen += end
// Extract link text
linkText := result[start+1 : start+end-start-2]
// Replace entire link with just text
result = result[:start] + linkText + result[closeParen+1:]
}
return result
}
// matchesQuery checks if content matches the search query
func (h *DocsHandler) matchesQuery(content, queryLower string) bool {
contentLower := strings.ToLower(content)
return strings.Contains(contentLower, queryLower)
}
// extractSnippet extracts a snippet around the matched term
func (h *DocsHandler) extractSnippet(content, queryLower string) string {
contentLower := strings.ToLower(content)
// Find first match
idx := strings.Index(contentLower, queryLower)
if idx == -1 {
return ""
}
// Extract context around match (100 chars before and after)
start := idx - 50
if start < 0 {
start = 0
}
end := idx + len(queryLower) + 50
if end > len(content) {
end = len(content)
}
snippet := content[start:end]
// Add ellipsis if truncated
if start > 0 {
snippet = "..." + snippet
}
if end < len(content) {
snippet = snippet + "..."
}
return snippet
}
2. Add HTTP Handler
File: internal/docs/http_handler.go (existing file - append to it)
package docs
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
// HandleSearch handles documentation search requests
func (h *DocsHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
// Extract query parameter
query := r.URL.Query().Get("q")
query = strings.TrimSpace(query)
// Validate query
if query == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "query parameter 'q' is required",
})
return
}
if len(query) < 2 {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "query must be at least 2 characters",
})
return
}
// Perform search
results, err := h.SearchDocuments(query)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "failed to search documents",
})
return
}
// Return results
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"query": query,
"count": len(results),
"results": results,
})
}
// RegisterDocsSearchRoutes registers search endpoints
func RegisterDocsSearchRoutes(r chi.Router, handler *DocsHandler) {
r.Get("/api/docs/search", handler.HandleSearch)
}
3. Register Route
File: internal/router/docs.go (modify existing)
Add the search route registration:
func RegisterDocsRoutes(r chi.Router, docsPath string) {
handler := docs.NewDocsHandler(docsPath)
// Existing routes...
r.Get("/docs", docsHandler)
r.Get("/docs/*", docsHandler)
// NEW: Search route
docs.RegisterDocsSearchRoutes(r, handler)
}
Frontend Changes
1. Update docs.ts to Remove Lunr
File: web/src/docs.ts
Remove broken imports (lines 1-4):
// DELETE THESE LINES:
import { docs } from "../data/docs.json";
import searchIndex from "../data/search_index.json";
import * as lunr from "lunr";
import hljs from "highlight.js";
Replace search function (lines 46-88):
// BEFORE:
function performDocsSearch(query: string): void {
const searchResults = document.getElementById("docs-search-results");
if (!searchResults) return;
try {
const idx = lunr.Builder.loadJs(searchIndex);
// ... lunr search logic
} catch (error) {
console.error("Search error:", error);
}
}
// AFTER:
async function performDocsSearch(query: string): Promise<void> {
const searchResults = document.getElementById("docs-search-results");
if (!searchResults) return;
try {
// Show loading state
searchResults.innerHTML = '<p class="p-2 text-sm" style="color: var(--text-secondary)">Searching...</p>';
searchResults.classList.remove("hidden");
// Fetch from backend API
const response = await fetch(`/api/docs/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error(`Search failed: ${response.statusText}`);
}
const data = await response.json();
if (!data.results || data.results.length === 0) {
searchResults.innerHTML =
'<p class="p-2 text-sm" style="color: var(--text-secondary)">No results found</p>';
} else {
searchResults.innerHTML = data.results
.slice(0, 10)
.map((result: { path: string; title: string; section: string; snippet?: string }) => {
return `
<a href="/docs/${result.path}" class="block p-2 hover:bg-opacity-50 transition-colors" style="background-color: var(--bg-secondary)">
<p class="font-medium text-sm" style="color: var(--text-primary)">${result.title || result.path}</p>
${result.section ? `<p class="text-xs" style="color: var(--text-secondary)">${result.section}</p>` : ""}
${result.snippet ? `<p class="text-xs mt-1" style="color: var(--text-secondary)">${result.snippet}</p>` : ""}
</a>
`;
})
.join("");
}
searchResults.classList.remove("hidden");
} catch (error) {
console.error("Search error:", error);
searchResults.innerHTML =
'<p class="p-2 text-sm" style="color: var(--text-secondary)">Search error</p>';
searchResults.classList.remove("hidden");
}
}
Keep the rest - toggleSidebar, initializeDocsSearch, and Alpine registration remain the same.
2. Remove Lunr Dependency
File: package.json
Remove from dependencies (line 16):
"lunr": "^2.3.9", // DELETE THIS LINE
Run:
npm uninstall lunr
Testing Strategy
1. Unit Tests (No API Required)
File: internal/docs/search_test.go (new file)
package docs
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMarkdownToText(t *testing.T) {
handler := &DocsHandler{}
testCases := []struct {
name string
input string
expected string
}{
{
name: "Simple text",
input: "Hello world",
expected: "Hello world",
},
{
name: "Remove headers",
input: "# Title\n\nSome content",
expected: "Title Some content",
},
{
name: "Remove bold",
input: "This is **bold** text",
expected: "This is bold text",
},
{
name: "Remove links",
input: "See [the docs](/docs/index.md) for more",
expected: "See the docs for more",
},
{
name: "Remove code blocks",
input: "```go\nfunc test() {}\n```",
expected: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := handler.markdownToText([]byte(tc.input))
assert.Equal(t, tc.expected, result)
})
}
}
func TestGetSectionFromPath(t *testing.T) {
handler := &DocsHandler{}
testCases := []struct {
name string
path string
expected string
}{
{
name: "API endpoint",
path: "developer/api/auth/login.md",
expected: "Developer > Api > Auth > Login",
},
{
name: "User guide",
path: "user/devices/kobo-setup.md",
expected: "User > Devices > Kobo Setup",
},
{
name: "Index file",
path: "index.md",
expected: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := handler.getSectionFromPath(tc.path)
assert.Equal(t, tc.expected, result)
})
}
}
func TestMatchesQuery(t *testing.T) {
handler := &DocsHandler{}
testCases := []struct {
name string
content string
query string
expected bool
}{
{
name: "Exact match",
content: "Kobo setup guide for e-readers",
query: "kobo",
expected: true,
},
{
name: "Case insensitive",
content: "Kobo Setup Guide",
query: "KOBO",
expected: true,
},
{
name: "No match",
content: "iPhone setup guide",
query: "kobo",
expected: false,
},
{
name: "Partial match",
content: "Configure your Kobo device",
query: "kobo device",
expected: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := handler.matchesQuery(tc.content, tc.query)
assert.Equal(t, tc.expected, result)
})
}
}
func TestExtractSnippet(t *testing.T) {
handler := &DocsHandler{}
testCases := []struct {
name string
content string
query string
expectSnippet bool
}{
{
name: "Match in middle",
content: "This is some long text with Kobo device configuration options",
query: "kobo device",
expectSnippet: true,
},
{
name: "No match",
content: "This is some text without the term",
query: "kobo",
expectSnippet: false,
},
{
name: "Match at start",
content: "Kobo setup guide for e-readers with detailed instructions",
query: "kobo",
expectSnippet: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := handler.extractSnippet(tc.content, tc.query)
if tc.expectSnippet {
assert.NotEmpty(t, result)
assert.Contains(t, result, "...")
} else {
assert.Empty(t, result)
}
})
}
}
func TestKebabToTitle(t *testing.T) {
testCases := []struct {
input string
expected string
}{
{"kobo-setup", "Kobo Setup"},
{"api-reference", "Api Reference"},
{"user-guide", "User Guide"},
{"auth", "Auth"},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
result := kebabToTitle(tc.input)
assert.Equal(t, tc.expected, result)
})
}
}
2. Integration Tests (with API)
File: cmd/server/tests/docs_search_test.go (new file)
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDocsSearchAPI(t *testing.T) {
// Setup test server
ts := setupTestServer(t)
defer ts.cleanup(t)
testCases := []struct {
name string
query string
expectedStatus int
expectResults bool
description string
}{
{
name: "Empty query",
query: "",
expectedStatus: http.StatusBadRequest,
expectResults: false,
description: "Should reject empty query",
},
{
name: "Single character",
query: "a",
expectedStatus: http.StatusBadRequest,
expectResults: false,
description: "Should reject queries < 2 chars",
},
{
name: "Valid search - kobo",
query: "kobo",
expectedStatus: http.StatusOK,
expectResults: true,
description: "Should find Kobo documentation",
},
{
name: "Valid search - API",
query: "API",
expectedStatus: http.StatusOK,
expectResults: true,
description: "Should find API documentation (case insensitive)",
},
{
name: "Valid search - no results",
query: "xyznonexistent",
expectedStatus: http.StatusOK,
expectResults: false,
description: "Should return empty results for non-existent term",
},
{
name: "Valid search - sync",
query: "sync",
expectedStatus: http.StatusOK,
expectResults: true,
description: "Should find sync documentation",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create request
req := httptest.NewRequest("GET", "/api/docs/search?q="+tc.query, nil)
rr := httptest.NewRecorder()
// Serve request
ts.handler.ServeHTTP(rr, req)
// Check status
assert.Equal(t, tc.expectedStatus, rr.Code, tc.description)
// Parse response
var response map[string]interface{}
err := json.Unmarshal(rr.Body.Bytes(), &response)
require.NoError(t, err)
// Validate response structure
if tc.expectedStatus == http.StatusOK {
assert.Contains(t, response, "query")
assert.Contains(t, response, "count")
assert.Contains(t, response, "results")
results, ok := response["results"].([]interface{})
require.True(t, ok, "results should be an array")
if tc.expectResults {
assert.Greater(t, len(results), 0, "Should have results")
// Check first result structure
if len(results) > 0 {
firstResult, ok := results[0].(map[string]interface{})
require.True(t, ok, "result should be an object")
assert.Contains(t, firstResult, "path")
assert.Contains(t, firstResult, "title")
assert.Contains(t, firstResult, "section")
}
} else {
assert.Equal(t, 0, len(results), "Should have no results")
}
} else {
assert.Contains(t, response, "error")
}
})
}
}
func TestDocsSearchResultStructure(t *testing.T) {
ts := setupTestServer(t)
defer ts.cleanup(t)
// Search for a known term
req := httptest.NewRequest("GET", "/api/docs/search?q=kobo", nil)
rr := httptest.NewRecorder()
ts.handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var response map[string]interface{}
err := json.Unmarshal(rr.Body.Bytes(), &response)
require.NoError(t, err)
results, ok := response["results"].([]interface{})
require.True(t, ok)
require.Greater(t, len(results), 0, "Should have at least one result for 'kobo'")
// Validate first result has all required fields
firstResult := results[0].(map[string]interface{})
assert.Contains(t, firstResult, "path", "Should have path")
assert.Contains(t, firstResult, "title", "Should have title")
assert.Contains(t, firstResult, "section", "Should have section")
// Check types
assert.IsType(t, "", firstResult["path"])
assert.IsType(t, "", firstResult["title"])
assert.IsType(t, "", firstResult["section"])
// Snippet is optional
if snippet, ok := firstResult["snippet"]; ok {
assert.IsType(t, "", snippet)
}
}
Note: setupTestServer() helper should be called once at the test function level (not per subtest). If test_helpers.go doesn't exist yet, create it following existing test patterns in cmd/server/tests/.
3. Bruno OpenCollection YAML
File: bruno/docs/Search Docs.yml (new file)
info:
name: Search Docs
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/docs/search?q=kobo'
auth: none
docs: |-
## Search Documentation
Performs full-text search across all documentation files.
**Method:** GET
**Endpoint:** /api/docs/search
**Query Parameters:**
- `q` (string, required): Search query (minimum 2 characters)
**Authentication:** Not required (public documentation)
**Response:**
```json
{
"query": "kobo",
"count": 5,
"results": [
{
"path": "user/devices/kobo-setup.md",
"title": "Kobo Setup Guide",
"section": "User > Devices > Kobo Setup",
"snippet": "Complete Kobo e-reader configuration instructions..."
}
]
}
Status Codes:
- 200: Success (results may be empty)
- 400: Bad request (missing or invalid query)
- 500: Internal server error
Examples:
# Search for "kobo"
GET /api/docs/search?q=kobo
# Search for "API authentication"
GET /api/docs/search?q=api%20authentication
# Case insensitive
GET /api/docs/search?q=KOBO
# Minimum 2 characters
GET /api/docs/search?q=a # Returns 400 Bad Request
Dependencies to Remove
1. Lunr (npm package)
File: package.json
Before (line 16):
"lunr": "^2.3.9",
After: Delete this line
Run:
npm uninstall lunr
2. Clean Up docs.ts
File: web/src/docs.ts
Remove imports (lines 1-4):
import { docs } from "../data/docs.json";
import searchIndex from "../data/search_index.json";
import * as lunr from "lunr";
import hljs from "highlight.js";
Note: hljs import can also be removed if not used elsewhere in the file.
Documentation Updates
1. API Documentation
File: docs/developer/api/docs/search.md (new file)
# Search Documentation
Search across all documentation files with full-text search.
## Endpoint
`GET /api/docs/search`
## Authentication
Not required (public documentation).
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|----------------------------------|
| q | string | Yes | Search query (min 2 characters) |
## Response
### Success (200 OK)
```json
{
"query": "kobo",
"count": 5,
"results": [
{
"path": "user/devices/kobo-setup.md",
"title": "Kobo Setup Guide",
"section": "User > Devices > Kobo Setup",
"snippet": "...Complete Kobo e-reader configuration..."
}
]
}
Bad Request (400)
Missing or invalid query:
{
"error": "query parameter 'q' is required"
}
Query too short:
{
"error": "query must be at least 2 characters"
}
Examples
# Search for Kobo documentation
curl "https://bookhoard.com/api/docs/search?q=kobo"
# Search for API endpoints
curl "https://bookhoard.com/api/docs/search?q=api%20authentication"
# Multi-word search
curl "https://bookhoard.com/api/docs/search?q=device%20sync"
Notes
- Search is case-insensitive
- Searches across title, section, and content
- Returns up to all matches (frontend can limit)
- Snippets include context around matched terms
- Results are not ranked (order depends on file system traversal)
### 2. Update Development Documentation
**File**: `docs/contributing/development.md`
Add section about the new search endpoint (if there's a section about API development or docs system).
### 3. Update README
**File**: `README.md`
If there's a section about documentation features, mention the new search functionality.
---
## Implementation Checklist
### Phase 1: Backend (1.5 hours)
- [ ] Create `internal/docs/search.go` with SearchDocuments method
- [ ] Add search handler to `internal/docs/http_handler.go`
- [ ] Register search route in `internal/router/docs.go`
- [ ] Write unit tests for search logic (`internal/docs/search_test.go`)
- [ ] Run unit tests: `go test ./internal/docs/... -v`
- [ ] Verify compilation: `go build ./...`
### Phase 2: Integration Testing (1 hour)
- [ ] Create `cmd/server/tests/docs_search_test.go`
- [ ] Implement table-driven integration tests
- [ ] Test all three contexts (no auth, user, admin) - though docs search doesn't require auth
- [ ] Run integration tests: `go test ./cmd/server/tests/... -v`
- [ ] Verify tests pass
### Phase 3: Frontend (1 hour)
- [ ] Update `web/src/docs.ts` to remove Lunr imports
- [ ] Replace performDocsSearch with async API call
- [ ] Test search in browser (manual testing)
- [ ] Verify no 404 errors for missing JSON files
### Phase 4: Cleanup (30 minutes)
- [ ] Remove Lunr from `package.json`
- [ ] Run `npm uninstall lunr`
- [ ] Rebuild frontend: `npm run build:ts`
- [ ] Verify no TypeScript errors
- [ ] Remove any references to `/web/src/data/` directory
### Phase 5: Documentation & Bruno (30 minutes)
- [ ] Create `bruno/docs/Search Docs.yml`
- [ ] Test endpoint with Bruno
- [ ] Create `docs/developer/api/docs/search.md`
- [ ] Update `docs/contributing/development.md` if needed
- [ ] Verify docs render at `/docs` endpoint
- [ ] Test docs search finds new API documentation
### Phase 6: Final Verification (30 minutes)
- [ ] Run full test suite: `go test ./... -v`
- [ ] Run verification script: `bash scripts/verify-guidelines.sh`
- [ ] Build entire project: `go build ./...`
- [ ] Test search in running application
- [ ] Verify all three test cases pass:
- Empty query → 400 error
- Valid search → results
- No results → empty array
- [ ] Check git diff for unintended changes
- [ ] Review all modified files
---
## Git Commit Strategy
Follow PROJECT_GUIDELINES.md - make multiple logical commits:
1. **Backend implementation** (search.go, http_handler.go, router changes)
2. **Unit tests** (search_test.go)
3. **Integration tests** (docs_search_test.go)
4. **Frontend changes** (docs.ts updates, remove Lunr)
5. **Cleanup** (package.json, remove data directory references)
6. **Documentation** (API docs, Bruno YAML, contributing guide)
---
## Success Criteria
✅ Backend API `/api/docs/search` returns 200 with results
✅ Empty/short queries return 400 Bad Request
✅ Unit tests pass for all search helper functions
✅ Integration tests pass with table-driven tests
✅ Bruno YAML validates successfully
✅ Frontend search works without errors
✅ No 404s for missing JSON files
✅ Lunr dependency removed from package.json
✅ API documentation created and renders
✅ Full test suite passes
✅ Verification script passes (0 errors)
---
## Rollback Plan
If issues arise:
1. **Backend**: Revert search.go, http_handler.go changes
2. **Tests**: Remove new test files
3. **Frontend**: Restore original docs.ts with Lunr code
4. **Dependencies**: Reinstall Lunr: `npm install lunr@^2.3.9`
Use `git diff` to identify changes and `git checkout` to revert specific files if needed.