chasm_cli/commands/
git.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Git integration commands
4
5use anyhow::{Context, Result};
6use colored::*;
7use std::path::Path;
8use std::process::Command;
9
10use crate::workspace::get_workspace_by_path;
11
12/// Configure git settings for chat sessions
13pub fn git_config(name: Option<&str>, email: Option<&str>, path: Option<&str>) -> Result<()> {
14    let project_dir = path.map(Path::new).unwrap_or_else(|| Path::new("."));
15
16    // Check if git repo exists
17    if !project_dir.join(".git").exists() {
18        anyhow::bail!("Not a git repository: {}", project_dir.display());
19    }
20
21    // If no options provided, show current config
22    if name.is_none() && email.is_none() {
23        println!("Git configuration for: {}", project_dir.display());
24
25        let output = Command::new("git")
26            .current_dir(project_dir)
27            .args(["config", "--local", "user.name"])
28            .output()?;
29        let current_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
30
31        let output = Command::new("git")
32            .current_dir(project_dir)
33            .args(["config", "--local", "user.email"])
34            .output()?;
35        let current_email = String::from_utf8_lossy(&output.stdout).trim().to_string();
36
37        println!(
38            "  Name:  {}",
39            if current_name.is_empty() {
40                "(not set)".to_string()
41            } else {
42                current_name
43            }
44        );
45        println!(
46            "  Email: {}",
47            if current_email.is_empty() {
48                "(not set)".to_string()
49            } else {
50                current_email
51            }
52        );
53        return Ok(());
54    }
55
56    // Set name if provided
57    if let Some(n) = name {
58        let output = Command::new("git")
59            .current_dir(project_dir)
60            .args(["config", "--local", "user.name", n])
61            .output()?;
62
63        if !output.status.success() {
64            anyhow::bail!(
65                "Failed to set git user.name: {}",
66                String::from_utf8_lossy(&output.stderr)
67            );
68        }
69        println!("{} Set git user.name = {}", "[OK]".green(), n);
70    }
71
72    // Set email if provided
73    if let Some(e) = email {
74        let output = Command::new("git")
75            .current_dir(project_dir)
76            .args(["config", "--local", "user.email", e])
77            .output()?;
78
79        if !output.status.success() {
80            anyhow::bail!(
81                "Failed to set git user.email: {}",
82                String::from_utf8_lossy(&output.stderr)
83            );
84        }
85        println!("{} Set git user.email = {}", "[OK]".green(), e);
86    }
87
88    Ok(())
89}
90
91/// Initialize git versioning for chat sessions
92pub fn git_init(project_path: &str) -> Result<()> {
93    let workspace = get_workspace_by_path(project_path)?
94        .context(format!("Workspace not found for path: {}", project_path))?;
95
96    let project_dir = Path::new(project_path);
97    let vscode_dir = project_dir.join(".vscode");
98    let symlink_path = vscode_dir.join("chat-sessions");
99
100    // Create .vscode directory if needed
101    std::fs::create_dir_all(&vscode_dir)?;
102
103    // Create symlink to chat sessions
104    if symlink_path.exists() {
105        println!("{} Chat versioning already initialized", "[!]".yellow());
106        println!("   Symlink: {}", symlink_path.display());
107        return Ok(());
108    }
109
110    #[cfg(unix)]
111    std::os::unix::fs::symlink(&workspace.chat_sessions_path, &symlink_path)?;
112
113    #[cfg(windows)]
114    std::os::windows::fs::symlink_dir(&workspace.chat_sessions_path, &symlink_path)?;
115
116    println!(
117        "{} Initialized git versioning for chat sessions",
118        "[OK]".green()
119    );
120    println!("   Symlink: {}", symlink_path.display());
121    println!("   Target: {}", workspace.chat_sessions_path.display());
122    println!("\nNext steps:");
123    println!("  1. Add .vscode/chat-sessions to your .gitignore if you want to exclude them");
124    println!(
125        "  2. Or commit them: csm add {} --commit -m 'Add chat sessions'",
126        project_path
127    );
128
129    Ok(())
130}
131
132/// Add chat sessions to git
133pub fn git_add(project_path: &str, commit: bool, message: Option<&str>) -> Result<()> {
134    let project_dir = Path::new(project_path);
135    let chat_sessions_path = project_dir.join(".vscode").join("chat-sessions");
136
137    if !chat_sessions_path.exists() {
138        anyhow::bail!(
139            "Chat versioning not initialized. Run 'csm init {}' first",
140            project_path
141        );
142    }
143
144    // Stage files
145    let output = Command::new("git")
146        .current_dir(project_dir)
147        .args(["add", ".vscode/chat-sessions"])
148        .output()?;
149
150    if !output.status.success() {
151        anyhow::bail!(
152            "Failed to stage chat sessions: {}",
153            String::from_utf8_lossy(&output.stderr)
154        );
155    }
156
157    println!("{} Staged chat sessions for commit", "[OK]".green());
158
159    // Commit if requested
160    if commit {
161        let msg = message.unwrap_or("Update chat sessions");
162
163        let output = Command::new("git")
164            .current_dir(project_dir)
165            .args(["commit", "-m", msg])
166            .output()?;
167
168        if !output.status.success() {
169            let stderr = String::from_utf8_lossy(&output.stderr);
170            if stderr.contains("nothing to commit") {
171                println!("{} Nothing to commit", "[i]".blue());
172            } else {
173                anyhow::bail!("Failed to commit: {}", stderr);
174            }
175        } else {
176            // Get commit hash
177            let output = Command::new("git")
178                .current_dir(project_dir)
179                .args(["rev-parse", "--short", "HEAD"])
180                .output()?;
181
182            let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
183            println!("{} Committed: {}", "[OK]".green(), hash);
184        }
185    }
186
187    Ok(())
188}
189
190/// Show git status of chat sessions
191pub fn git_status(project_path: &str) -> Result<()> {
192    let project_dir = Path::new(project_path);
193
194    // Check if it's a git repo
195    let is_git_repo = project_dir.join(".git").exists();
196
197    // Check if versioning is enabled
198    let chat_sessions_path = project_dir.join(".vscode").join("chat-sessions");
199    let versioning_enabled = chat_sessions_path.exists();
200
201    // Get workspace info
202    let workspace = get_workspace_by_path(project_path)?;
203    let session_count = workspace.map(|w| w.chat_session_count).unwrap_or(0);
204
205    println!("Project: {}", project_path);
206    println!("Git repository: {}", if is_git_repo { "Yes" } else { "No" });
207    println!(
208        "Chat versioning: {}",
209        if versioning_enabled {
210            "Enabled"
211        } else {
212            "Disabled"
213        }
214    );
215    println!("Total sessions: {}", session_count);
216
217    if versioning_enabled && is_git_repo {
218        // Get git status for chat sessions
219        let output = Command::new("git")
220            .current_dir(project_dir)
221            .args(["status", "--porcelain", ".vscode/chat-sessions"])
222            .output()?;
223
224        let status = String::from_utf8_lossy(&output.stdout);
225        let lines: Vec<&str> = status.lines().collect();
226
227        let modified: Vec<_> = lines
228            .iter()
229            .filter(|l| l.starts_with(" M") || l.starts_with("M "))
230            .collect();
231        let untracked: Vec<_> = lines.iter().filter(|l| l.starts_with("??")).collect();
232        let staged: Vec<_> = lines
233            .iter()
234            .filter(|l| l.starts_with("A ") || l.starts_with("M "))
235            .collect();
236
237        println!("\nGit status:");
238        println!("  Modified: {}", modified.len());
239        println!("  Untracked: {}", untracked.len());
240        println!("  Staged: {}", staged.len());
241
242        if !modified.is_empty() {
243            println!("\n  Modified files:");
244            for f in modified.iter().take(5) {
245                println!(
246                    "    - {}",
247                    f.trim_start_matches(|c: char| c.is_whitespace() || c == 'M')
248                );
249            }
250            if modified.len() > 5 {
251                println!("    ... and {} more", modified.len() - 5);
252            }
253        }
254    }
255
256    Ok(())
257}
258
259/// Create a git tag snapshot of chat sessions
260pub fn git_snapshot(project_path: &str, tag: Option<&str>, message: Option<&str>) -> Result<()> {
261    let project_dir = Path::new(project_path);
262    let chat_sessions_path = project_dir.join(".vscode").join("chat-sessions");
263
264    if !chat_sessions_path.exists() {
265        anyhow::bail!(
266            "Chat versioning not initialized. Run 'csm init {}' first",
267            project_path
268        );
269    }
270
271    // Generate tag name if not provided
272    let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
273    let tag_name = tag
274        .map(|t| t.to_string())
275        .unwrap_or_else(|| format!("chat-snapshot-{}", timestamp));
276
277    let msg = message.unwrap_or("Chat session snapshot");
278
279    // Stage and commit
280    let _ = Command::new("git")
281        .current_dir(project_dir)
282        .args(["add", ".vscode/chat-sessions"])
283        .output()?;
284
285    let _ = Command::new("git")
286        .current_dir(project_dir)
287        .args(["commit", "-m", &format!("Snapshot: {}", msg)])
288        .output()?;
289
290    // Create tag
291    let output = Command::new("git")
292        .current_dir(project_dir)
293        .args(["tag", "-a", &tag_name, "-m", msg])
294        .output()?;
295
296    if !output.status.success() {
297        anyhow::bail!(
298            "Failed to create tag: {}",
299            String::from_utf8_lossy(&output.stderr)
300        );
301    }
302
303    // Get commit hash
304    let output = Command::new("git")
305        .current_dir(project_dir)
306        .args(["rev-parse", "--short", "HEAD"])
307        .output()?;
308
309    let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
310
311    println!("{} Created snapshot", "[OK]".green());
312    println!("   Tag: {}", tag_name);
313    println!("   Commit: {}", hash);
314
315    Ok(())
316}
317
318/// Track chat sessions together with associated file changes
319pub fn git_track(
320    project_path: &str,
321    message: Option<&str>,
322    all: bool,
323    files: Option<&[String]>,
324    tag: Option<&str>,
325) -> Result<()> {
326    let project_dir = Path::new(project_path);
327    let chat_sessions_path = project_dir.join(".vscode").join("chat-sessions");
328
329    if !chat_sessions_path.exists() {
330        anyhow::bail!(
331            "Chat versioning not initialized. Run 'csm git init {}' first",
332            project_path
333        );
334    }
335
336    println!(
337        "{} Tracking chat sessions with file changes",
338        "[*]".blue().bold()
339    );
340    println!("{}", "=".repeat(60));
341
342    // Stage chat sessions
343    let output = Command::new("git")
344        .current_dir(project_dir)
345        .args(["add", ".vscode/chat-sessions"])
346        .output()?;
347
348    if !output.status.success() {
349        anyhow::bail!(
350            "Failed to stage chat sessions: {}",
351            String::from_utf8_lossy(&output.stderr)
352        );
353    }
354
355    println!("{} Staged chat sessions", "[OK]".green());
356
357    // Stage additional files if requested
358    if all {
359        let output = Command::new("git")
360            .current_dir(project_dir)
361            .args(["add", "-A"])
362            .output()?;
363
364        if output.status.success() {
365            println!("{} Staged all changes", "[OK]".green());
366        }
367    } else if let Some(file_list) = files {
368        for file in file_list {
369            let output = Command::new("git")
370                .current_dir(project_dir)
371                .args(["add", file])
372                .output()?;
373
374            if output.status.success() {
375                println!("{} Staged: {}", "[OK]".green(), file);
376            } else {
377                println!("{} Failed to stage: {}", "[!]".yellow(), file);
378            }
379        }
380    }
381
382    // Get status summary
383    let output = Command::new("git")
384        .current_dir(project_dir)
385        .args(["diff", "--cached", "--stat"])
386        .output()?;
387
388    let stat = String::from_utf8_lossy(&output.stdout);
389    if !stat.is_empty() {
390        println!("\n{} Changes to be committed:", "[*]".blue());
391        for line in stat.lines().take(10) {
392            println!("   {}", line);
393        }
394        if stat.lines().count() > 10 {
395            println!("   ... and {} more files", stat.lines().count() - 10);
396        }
397    }
398
399    // Generate commit message
400    let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M").to_string();
401    let default_msg = format!("Track chat sessions with changes ({})", timestamp);
402    let commit_msg = message.unwrap_or(&default_msg);
403
404    // Create commit
405    let output = Command::new("git")
406        .current_dir(project_dir)
407        .args(["commit", "-m", commit_msg])
408        .output()?;
409
410    if !output.status.success() {
411        let stderr = String::from_utf8_lossy(&output.stderr);
412        if stderr.contains("nothing to commit") {
413            println!("\n{} Nothing to commit", "[i]".blue());
414            return Ok(());
415        }
416        anyhow::bail!("Failed to commit: {}", stderr);
417    }
418
419    // Get commit hash
420    let output = Command::new("git")
421        .current_dir(project_dir)
422        .args(["rev-parse", "--short", "HEAD"])
423        .output()?;
424
425    let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
426    println!("\n{} Committed: {}", "[OK]".green(), hash);
427
428    // Create tag if requested
429    if let Some(tag_name) = tag {
430        let output = Command::new("git")
431            .current_dir(project_dir)
432            .args(["tag", "-a", tag_name, "-m", commit_msg])
433            .output()?;
434
435        if output.status.success() {
436            println!("{} Tagged: {}", "[OK]".green(), tag_name);
437        }
438    }
439
440    Ok(())
441}
442
443/// Show history of chat session commits with associated file changes
444pub fn git_log(project_path: &str, count: usize, sessions_only: bool) -> Result<()> {
445    let project_dir = Path::new(project_path);
446
447    println!("{} Chat Session History", "[*]".blue().bold());
448    println!("{}", "=".repeat(60));
449
450    // Build log command
451    let mut args = vec![
452        "log".to_string(),
453        format!("-{}", count),
454        "--pretty=format:%h|%ad|%s".to_string(),
455        "--date=short".to_string(),
456    ];
457
458    if sessions_only {
459        args.push("--".to_string());
460        args.push(".vscode/chat-sessions".to_string());
461    }
462
463    let output = Command::new("git")
464        .current_dir(project_dir)
465        .args(&args)
466        .output()?;
467
468    if !output.status.success() {
469        anyhow::bail!(
470            "Failed to get git log: {}",
471            String::from_utf8_lossy(&output.stderr)
472        );
473    }
474
475    let log = String::from_utf8_lossy(&output.stdout);
476
477    if log.is_empty() {
478        println!("\n{} No commits found", "[i]".blue());
479        return Ok(());
480    }
481
482    println!();
483    for line in log.lines() {
484        let parts: Vec<&str> = line.split('|').collect();
485        if parts.len() >= 3 {
486            let hash = parts[0];
487            let date = parts[1];
488            let message = parts[2];
489
490            // Check if this commit touched chat sessions
491            let output = Command::new("git")
492                .current_dir(project_dir)
493                .args(["diff-tree", "--no-commit-id", "--name-only", "-r", hash])
494                .output()?;
495
496            let files = String::from_utf8_lossy(&output.stdout);
497            let has_chat = files.contains("chat-sessions");
498            let file_count = files.lines().count();
499
500            let chat_marker = if has_chat {
501                "[chat]".cyan()
502            } else {
503                "      ".normal()
504            };
505
506            println!(
507                "{} {} {} {} ({})",
508                hash.yellow(),
509                chat_marker,
510                date.dimmed(),
511                message,
512                format!("{} files", file_count).dimmed()
513            );
514        }
515    }
516
517    println!();
518    println!(
519        "{} Use 'csm git diff --from <hash>' to see changes",
520        "[i]".cyan()
521    );
522
523    Ok(())
524}
525
526/// Diff chat sessions between commits or current state
527pub fn git_diff(
528    project_path: &str,
529    from: Option<&str>,
530    to: Option<&str>,
531    with_files: bool,
532) -> Result<()> {
533    let project_dir = Path::new(project_path);
534
535    let from_ref = from.unwrap_or("HEAD");
536    let to_ref = to.unwrap_or("");
537
538    println!("{} Chat Session Diff", "[*]".blue().bold());
539    println!("{}", "=".repeat(60));
540
541    if to_ref.is_empty() {
542        println!("{} {}..working directory", "[>]".blue(), from_ref);
543    } else {
544        println!("{} {}..{}", "[>]".blue(), from_ref, to_ref);
545    }
546
547    // Get diff for chat sessions
548    let mut diff_args = vec!["diff".to_string()];
549    if to_ref.is_empty() {
550        diff_args.push(from_ref.to_string());
551    } else {
552        diff_args.push(format!("{}..{}", from_ref, to_ref));
553    }
554    diff_args.push("--stat".to_string());
555    diff_args.push("--".to_string());
556    diff_args.push(".vscode/chat-sessions".to_string());
557
558    let output = Command::new("git")
559        .current_dir(project_dir)
560        .args(&diff_args)
561        .output()?;
562
563    let chat_diff = String::from_utf8_lossy(&output.stdout);
564
565    if chat_diff.is_empty() {
566        println!("\n{} No changes to chat sessions", "[i]".blue());
567    } else {
568        println!("\n{} Chat session changes:", "[*]".cyan());
569        for line in chat_diff.lines() {
570            println!("   {}", line);
571        }
572    }
573
574    // Show associated file changes if requested
575    if with_files {
576        let mut file_diff_args = vec!["diff".to_string()];
577        if to_ref.is_empty() {
578            file_diff_args.push(from_ref.to_string());
579        } else {
580            file_diff_args.push(format!("{}..{}", from_ref, to_ref));
581        }
582        file_diff_args.push("--stat".to_string());
583
584        let output = Command::new("git")
585            .current_dir(project_dir)
586            .args(&file_diff_args)
587            .output()?;
588
589        let file_diff = String::from_utf8_lossy(&output.stdout);
590
591        if !file_diff.is_empty() {
592            println!("\n{} All file changes:", "[*]".cyan());
593            for line in file_diff.lines().take(20) {
594                println!("   {}", line);
595            }
596            if file_diff.lines().count() > 20 {
597                println!("   ... and {} more", file_diff.lines().count() - 20);
598            }
599        }
600    }
601
602    Ok(())
603}
604
605/// Restore chat sessions from a specific commit
606pub fn git_restore(project_path: &str, commit: &str, with_files: bool, backup: bool) -> Result<()> {
607    let project_dir = Path::new(project_path);
608    let chat_sessions_path = project_dir.join(".vscode").join("chat-sessions");
609
610    println!("{} Restoring Chat Sessions", "[*]".blue().bold());
611    println!("{}", "=".repeat(60));
612    println!("{} From commit: {}", "[>]".blue(), commit);
613
614    // Verify commit exists
615    let output = Command::new("git")
616        .current_dir(project_dir)
617        .args(["rev-parse", "--verify", commit])
618        .output()?;
619
620    if !output.status.success() {
621        anyhow::bail!("Commit not found: {}", commit);
622    }
623
624    // Create backup if requested
625    if backup && chat_sessions_path.exists() {
626        let backup_name = format!("chat-sessions-backup-{}", chrono::Utc::now().timestamp());
627        let backup_path = project_dir.join(".vscode").join(&backup_name);
628
629        if let Err(e) = std::fs::rename(&chat_sessions_path, &backup_path) {
630            println!("{} Failed to create backup: {}", "[!]".yellow(), e);
631        } else {
632            println!("{} Created backup: {}", "[OK]".green(), backup_name);
633        }
634    }
635
636    // Restore chat sessions
637    let output = Command::new("git")
638        .current_dir(project_dir)
639        .args(["checkout", commit, "--", ".vscode/chat-sessions"])
640        .output()?;
641
642    if !output.status.success() {
643        anyhow::bail!(
644            "Failed to restore chat sessions: {}",
645            String::from_utf8_lossy(&output.stderr)
646        );
647    }
648
649    println!("{} Restored chat sessions from {}", "[OK]".green(), commit);
650
651    // Restore associated files if requested
652    if with_files {
653        println!(
654            "\n{} This will restore ALL files from commit {}!",
655            "[!]".yellow().bold(),
656            commit
657        );
658        println!(
659            "{} Are you sure? Use git checkout directly for selective restore.",
660            "[i]".cyan()
661        );
662        // We don't actually restore all files - too dangerous
663        // Instead, show what would be restored
664
665        let output = Command::new("git")
666            .current_dir(project_dir)
667            .args(["diff", "--name-only", commit])
668            .output()?;
669
670        let files = String::from_utf8_lossy(&output.stdout);
671        let file_count = files.lines().count();
672
673        if file_count > 0 {
674            println!("\n{} Files that differ from {}:", "[*]".cyan(), commit);
675            for line in files.lines().take(10) {
676                println!("   {}", line);
677            }
678            if file_count > 10 {
679                println!("   ... and {} more", file_count - 10);
680            }
681            println!(
682                "\n{} To restore all: git checkout {} -- .",
683                "[i]".cyan(),
684                commit
685            );
686        }
687    }
688
689    Ok(())
690}