use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use cc_sync_session::{RealFileSystem, SessionSyncer, SyncOptions};
use log::warn;
use cc_sync_session::file_path_converter::dir_path_to_claude_code_stype;
use git2::Repository;
use std::fs;
use std::io::{Read, Write};
#[derive(Parser, Debug)]
#[command(name = "cc-sync-session")]
#[command(about = "Sync Claude Code session files to a repository", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(short, long, global = true)]
verbose: bool,
}
#[derive(Subcommand, Debug)]
enum Commands {
Init {
#[arg(short = 'r', long)]
repo_dir: Option<PathBuf>,
},
Sync {
#[arg(short, long)]
source_dir: Option<PathBuf>,
#[arg(short = 'r', long)]
repo_dir: Option<PathBuf>,
#[arg(short, long)]
dry_run: bool,
#[arg(long)]
git_add: bool,
},
}
fn find_repo_dir(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
loop {
let git_dir = current.join(".git");
let ccss_dir = current.join(".claude").join("ccss_sessions");
log::debug!("Checking directory: {} git: {}, ccss: {}",
current.display(),
git_dir.exists(),
ccss_dir.exists());
if git_dir.exists() && ccss_dir.exists() {
return Some(current);
}
if !current.pop() {
break;
}
}
None
}
fn find_git_repo(start: &Path) -> Option<PathBuf> {
let mut current = start.to_path_buf();
loop {
let git_dir = current.join(".git");
if git_dir.exists() {
return Some(current);
}
if !current.pop() {
break;
}
}
None
}
fn init_command(repo_dir: Option<PathBuf>) -> Result<()> {
let repo_dir = match repo_dir {
Some(dir) => dir,
None => {
let current_dir = std::env::current_dir()
.context("Failed to get current directory")?;
find_git_repo(¤t_dir)
.context("No git repository found in current directory or parent directories")?
}
};
let ccss_dir = repo_dir.join(".claude").join("ccss_sessions");
std::fs::create_dir_all(&ccss_dir)
.context("Failed to create .claude/ccss_sessions directory")?;
let gitkeep_path = ccss_dir.join(".gitkeep");
std::fs::write(&gitkeep_path, "")
.context("Failed to create .gitkeep file")?;
let gitattributes_path = repo_dir.join(".gitattributes");
let lfs_line = ".claude/ccss_sessions/** filter=lfs diff=lfs merge=lfs -text";
let mut content = String::new();
let mut needs_newline = false;
if gitattributes_path.exists() {
let mut file = fs::File::open(&gitattributes_path)
.context("Failed to open .gitattributes file")?;
file.read_to_string(&mut content)
.context("Failed to read .gitattributes file")?;
if content.lines().any(|line| line.trim() == lfs_line) {
println!("Git LFS configuration already exists in .gitattributes");
} else {
needs_newline = !content.is_empty() && !content.ends_with('\n');
}
}
if !gitattributes_path.exists() || !content.lines().any(|line| line.trim() == lfs_line) {
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&gitattributes_path)
.context("Failed to open .gitattributes file for writing")?;
if needs_newline {
writeln!(file)?;
}
writeln!(file, "{}", lfs_line)
.context("Failed to write to .gitattributes file")?;
println!("Added Git LFS configuration to .gitattributes");
}
println!("Initialized session sync directory at: {}", ccss_dir.display());
println!("Created: {}", gitkeep_path.display());
Ok(())
}
fn sync_command(source_dir: Option<PathBuf>, repo_dir: Option<PathBuf>, dry_run: bool, git_add: bool, verbose: bool) -> Result<()> {
let repo_dir = match repo_dir {
Some(dir) => dir,
None => {
let current_dir = std::env::current_dir()
.context("Failed to get current directory")?;
find_repo_dir(¤t_dir)
.context("No repository with .claude/ccss_sessions found. Run 'cc-sync-session init' first")?
}
};
log::info!("Using repository directory: {}", repo_dir.display());
let repo_dir_cc_style = dir_path_to_claude_code_stype(repo_dir.clone())?;
log::debug!("Converted repository directory to Claude Code style: {}", repo_dir_cc_style);
let source_root_dir = match source_dir {
Some(dir) => dir,
None => {
if let Ok(env_source) = std::env::var("CC_SYNC_SESSION_SOURCE_DIR") {
PathBuf::from(env_source)
} else {
let home = dirs::home_dir()
.context("Failed to get home directory")?;
home.join(".claude").join("projects")
}
}
};
let source_dir = source_root_dir.join(&repo_dir_cc_style);
log::info!("Using source directory: {}", source_dir.display());
let target_dir = repo_dir.join(".claude").join("ccss_sessions");
if !source_dir.exists() {
anyhow::bail!("Source directory does not exist: {}", source_dir.display());
}
if !source_dir.is_dir() {
anyhow::bail!("Source path is not a directory: {}", source_dir.display());
}
if !target_dir.exists() {
anyhow::bail!("Target directory does not exist: {}. Run 'cc-sync-session init' first", target_dir.display());
}
println!("Syncing Claude Code sessions:");
println!(" Source: {}", source_dir.display());
println!(" Target: {}", target_dir.display());
if dry_run {
println!(" Mode: DRY RUN (no changes will be made)");
}
println!();
let filesystem = RealFileSystem::new();
let syncer = SessionSyncer::new(filesystem);
let options = SyncOptions {
dry_run,
verbose,
};
let result = syncer.sync(&source_root_dir, repo_dir_cc_style.as_str(), &target_dir, &options)
.context("Failed to sync sessions")?;
println!("\nSync completed:");
println!(" Files copied: {}", result.files_copied);
println!(" Files skipped: {}", result.files_skipped);
println!(" Directories created: {}", result.directories_created);
if !result.errors.is_empty() {
println!("\nErrors encountered:");
for error in &result.errors {
warn!("{}", error);
eprintln!(" - {}", error);
}
}
if git_add && !dry_run && result.files_copied > 0 {
let repo = Repository::open(&repo_dir)
.context("Failed to open git repository")?;
let ccss_sessions_path = target_dir.strip_prefix(&repo_dir)
.unwrap_or(&target_dir);
let mut index = repo.index()
.context("Failed to get repository index")?;
index.add_all([ccss_sessions_path], git2::IndexAddOption::DEFAULT, None)
.context("Failed to add .claude/ccss_sessions to git index")?;
index.write()
.context("Failed to write git index")?;
println!("\nAdded {} to git index", ccss_sessions_path.display());
}
Ok(())
}
fn main() -> Result<()> {
let cli = Cli::parse();
let log_level = if cli.verbose {
"info"
} else {
"warn"
};
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level))
.target(env_logger::Target::Stderr)
.init();
match cli.command {
Commands::Init { repo_dir } => init_command(repo_dir),
Commands::Sync { source_dir, repo_dir, dry_run, git_add } => {
sync_command(source_dir, repo_dir, dry_run, git_add, cli.verbose)
}
}
}