1use anyhow::{Context, Result};
6use colored::*;
7use std::path::Path;
8use std::process::Command;
9
10use crate::workspace::get_workspace_by_path;
11
12pub 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 if !project_dir.join(".git").exists() {
18 anyhow::bail!("Not a git repository: {}", project_dir.display());
19 }
20
21 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 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 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
91pub 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 std::fs::create_dir_all(&vscode_dir)?;
102
103 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
132pub 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 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 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 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
190pub fn git_status(project_path: &str) -> Result<()> {
192 let project_dir = Path::new(project_path);
193
194 let is_git_repo = project_dir.join(".git").exists();
196
197 let chat_sessions_path = project_dir.join(".vscode").join("chat-sessions");
199 let versioning_enabled = chat_sessions_path.exists();
200
201 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 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
259pub 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 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 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 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 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
318pub 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 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 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 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 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 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 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 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
443pub 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 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 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
526pub 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 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 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
605pub 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 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 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 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 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 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}