Skip to main content

git_semantic/cli/
commands.rs

1use anyhow::{Context, Result};
2use indicatif::{ProgressBar, ProgressStyle};
3use std::path::Path;
4use tracing::info;
5
6use crate::embedding::ModelManager;
7use crate::git::{GitError, RepositoryParser};
8use crate::index::{IndexBuilder, IndexError, IndexStorage, SemanticIndex};
9use crate::search::SearchEngine;
10
11use super::SearchFilters;
12
13pub fn init(force: bool) -> Result<()> {
14    println!("šŸš€ Initializing git-semantic...\n");
15
16    let model_manager = ModelManager::new()?;
17
18    if force || !model_manager.is_model_downloaded() {
19        println!("šŸ“„ Downloading embedding model (bge-small-en-v1.5, ~130MB)...");
20        println!("This is a one-time setup and may take a few minutes.\n");
21
22        let pb = ProgressBar::new_spinner();
23        pb.set_style(
24            ProgressStyle::default_spinner()
25                .template("{spinner:.green} {msg}")
26                .unwrap(),
27        );
28        pb.set_message("Downloading model...");
29
30        model_manager.download_model()?;
31
32        pb.finish_with_message("āœ… Model downloaded successfully!");
33    } else {
34        println!("āœ… Model already downloaded");
35    }
36
37    println!("\nšŸŽ‰ git-semantic is ready to use!");
38    println!("\nNext steps:");
39    println!("  1. Navigate to a git repository");
40    println!("  2. Run: git-semantic index");
41    println!("  3. Run: git-semantic search \"your query\"");
42
43    Ok(())
44}
45
46pub fn index(repo_path: &str, include_diffs: bool, force: bool) -> Result<()> {
47    let path = Path::new(repo_path);
48    let storage = IndexStorage::new(path)?;
49
50    let existing_index = match storage.load() {
51        Ok(idx) => Some(idx),
52        Err(IndexError::IndexNotFound) => None,
53        Err(e) => return Err(e).context("Failed to load existing index"),
54    };
55
56    match existing_index {
57        Some(existing) => {
58            let existing_mode = existing.metadata.include_diffs;
59
60            if force {
61                // Force rebuild — warn if downgrading from full to quick
62                if existing_mode && !include_diffs {
63                    println!(
64                        "āš ļø  Downgrading from full mode to quick mode. This will discard \
65                         diff embeddings for {} commits.\n\
66                         Switching back to full mode later will require re-embedding all commits.\n",
67                        existing.entries.len()
68                    );
69                }
70                return full_index(path, &storage, include_diffs);
71            }
72
73            if existing_mode != include_diffs {
74                if !include_diffs && existing_mode {
75                    // Full index already exists, quick is a superset — just do incremental with full mode
76                    println!(
77                        "ā„¹ļø  Index was built in full mode (with diffs), which is a superset of quick mode.\n\
78                         Keeping full mode and checking for new commits.\n\
79                         To downgrade to quick mode (smaller index), run with --force.\n\
80                         Note: switching back to full mode later will require re-embedding all commits.\n"
81                    );
82                    incremental_index(path, &storage, existing, existing_mode)?;
83                } else {
84                    // Quick index exists, full requested — requires re-embedding everything
85                    println!(
86                        "āš ļø  Index was built in quick mode (messages only). Switching to full mode \
87                         (with diffs) requires re-embedding all {} commits.\n\
88                         Run with --force to rebuild the index.",
89                        existing.entries.len()
90                    );
91                }
92                return Ok(());
93            }
94
95            incremental_index(path, &storage, existing, include_diffs)?;
96        }
97        None => {
98            full_index(path, &storage, include_diffs)?;
99        }
100    }
101
102    Ok(())
103}
104
105fn full_index(path: &Path, storage: &IndexStorage, include_diffs: bool) -> Result<()> {
106    let mode = if include_diffs { "full" } else { "quick" };
107    println!(
108        "šŸ“š Indexing repository ({} mode): {}\n",
109        mode,
110        path.display()
111    );
112
113    info!("Parsing git repository...");
114    let parser = RepositoryParser::new(path)?;
115    let commits = parser.parse_commits(include_diffs)?;
116
117    println!("Found {} commits to index\n", commits.len());
118
119    let model_manager = ModelManager::new()?;
120    let mut builder = IndexBuilder::new(model_manager, include_diffs)?;
121
122    // Commits are in newest-first order from revwalk; track HEAD as last_commit
123    if let Some(first) = commits.first() {
124        builder.set_last_commit(first.hash.clone());
125    }
126
127    let pb = make_progress_bar(commits.len() as u64);
128
129    for commit in commits {
130        builder.add_commit(commit)?;
131        pb.inc(1);
132    }
133
134    pb.finish_with_message("āœ… Commits indexed");
135
136    println!("\nšŸ’¾ Saving index...");
137    let index = builder.build();
138    storage.save(&index)?;
139
140    print_index_stats(&index, storage)?;
141
142    Ok(())
143}
144
145fn incremental_index(
146    path: &Path,
147    storage: &IndexStorage,
148    existing: SemanticIndex,
149    include_diffs: bool,
150) -> Result<()> {
151    let parser = RepositoryParser::new(path)?;
152
153    let new_commits = match parser.parse_commits_since(&existing.last_commit, include_diffs) {
154        Ok(commits) => commits,
155        Err(GitError::CommitNotFound(_)) => {
156            println!(
157                "āš ļø  Previously indexed commit {} not found in history (was the branch rebased?).",
158                &existing.last_commit[..7.min(existing.last_commit.len())]
159            );
160            println!("Re-indexing from scratch...\n");
161            return full_index(path, storage, include_diffs);
162        }
163        Err(err) => {
164            return Err(err.into());
165        }
166    };
167
168    if new_commits.is_empty() {
169        println!(
170            "āœ… Index is already up to date! ({} commits indexed)",
171            existing.entries.len()
172        );
173        return Ok(());
174    }
175
176    let mode = if include_diffs { "full" } else { "quick" };
177    println!(
178        "šŸ“š Updating index ({} mode): {} ({} new commits)\n",
179        mode,
180        path.display(),
181        new_commits.len()
182    );
183
184    let model_manager = ModelManager::new()?;
185    let mut builder = IndexBuilder::from_existing(existing, model_manager)?;
186
187    // New commits are newest-first; update last_commit to the newest
188    if let Some(first) = new_commits.first() {
189        builder.set_last_commit(first.hash.clone());
190    }
191
192    let pb = make_progress_bar(new_commits.len() as u64);
193
194    for commit in new_commits {
195        builder.add_commit(commit)?;
196        pb.inc(1);
197    }
198
199    pb.finish_with_message("āœ… New commits indexed");
200
201    println!("\nšŸ’¾ Saving index...");
202    let index = builder.build();
203    storage.save(&index)?;
204
205    print_index_stats(&index, storage)?;
206
207    Ok(())
208}
209
210pub fn update(repo_path: &str) -> Result<()> {
211    println!(
212        "Note: `git-semantic index` now automatically handles incremental updates.\n\
213         The `update` command will be removed in a future release.\n"
214    );
215    index(repo_path, true, false)
216}
217
218pub fn search(
219    repo_path: &str,
220    query: &str,
221    num_results: usize,
222    filters: SearchFilters,
223) -> Result<()> {
224    let path = Path::new(repo_path);
225
226    let storage = IndexStorage::new(path)?;
227    let index = storage.load()?;
228
229    let model_manager = ModelManager::new()?;
230    let mut engine = SearchEngine::new(model_manager)?;
231    let results = engine.search(&index, query, num_results, filters)?;
232
233    if results.is_empty() {
234        println!("No results found for: \"{}\"", query);
235        return Ok(());
236    }
237
238    println!("šŸŽÆ Most Relevant Commits for: \"{}\"\n", query);
239
240    for result in results {
241        println!(
242            "{}. {} - {} ({:.2} similarity)",
243            result.rank,
244            &result.commit.hash[..7],
245            result.commit.message.lines().next().unwrap_or(""),
246            result.similarity
247        );
248        println!(
249            "   Author: {}, {}",
250            result.commit.author, result.commit.date
251        );
252
253        if !result.commit.diff_summary.is_empty() {
254            let preview: String = result
255                .commit
256                .diff_summary
257                .lines()
258                .take(2)
259                .collect::<Vec<_>>()
260                .join("\n   ");
261            if !preview.is_empty() {
262                println!("   {}", preview);
263            }
264        }
265
266        println!();
267    }
268
269    Ok(())
270}
271
272pub fn stats(repo_path: &str) -> Result<()> {
273    let path = Path::new(repo_path);
274
275    let storage = IndexStorage::new(path)?;
276    let index = storage.load()?;
277
278    println!("šŸ“Š Index Statistics\n");
279    println!("Repository: {}", path.display());
280    println!("Total commits indexed: {}", index.entries.len());
281    println!("Model version: {}", index.model_version);
282    println!("Last indexed commit: {}", index.last_commit);
283    println!(
284        "Index mode: {}",
285        if index.metadata.include_diffs {
286            "full (with diffs)"
287        } else {
288            "quick (messages only)"
289        }
290    );
291    println!("Index size: ~{:.2} MB", storage.index_size_mb()?);
292    println!(
293        "Created: {}",
294        index.metadata.created_at.format("%Y-%m-%d %H:%M:%S")
295    );
296    println!(
297        "Last updated: {}",
298        index.metadata.updated_at.format("%Y-%m-%d %H:%M:%S")
299    );
300
301    Ok(())
302}
303
304fn make_progress_bar(total: u64) -> ProgressBar {
305    let pb = ProgressBar::new(total);
306    pb.set_style(
307        ProgressStyle::default_bar()
308            .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} {msg}")
309            .unwrap()
310            .progress_chars("=>-"),
311    );
312    pb
313}
314
315fn print_index_stats(index: &SemanticIndex, storage: &IndexStorage) -> Result<()> {
316    println!("āœ… Index saved successfully!");
317    println!("\nšŸ“Š Index statistics:");
318    println!("  - Total commits: {}", index.entries.len());
319    println!(
320        "  - Mode: {}",
321        if index.metadata.include_diffs {
322            "full (with diffs)"
323        } else {
324            "quick (messages only)"
325        }
326    );
327    println!("  - Model: {}", index.model_version);
328    println!("  - Index size: ~{:.2} MB", storage.index_size_mb()?);
329    Ok(())
330}