cc-sync-session 0.4.0

A tool for syncing Claude Code session files
Documentation
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::fs;
use std::io::{Read, Write};
use serde::Deserialize;
use serde_json;
use cc_sync_session::{RealFileSystem, FileSystem};
use cc_sync_session::file_path_converter::dir_path_to_claude_code_stype;

#[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,
    
    /// Enable verbose output
    #[arg(short, long, global = true)]
    verbose: bool,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// Initialize a repository for session syncing
    Init {
        /// Repository directory (defaults to current directory or parent with .git)
        #[arg(short = 'r', long)]
        repo_dir: Option<PathBuf>,
    },
    
    /// Process PostToolUse hook from Claude Code
    Hook {
        /// Repository directory (defaults to current directory or parent with .git)
        #[arg(short = 'r', long)]
        repo_dir: Option<PathBuf>,
        
        /// Automatically add synced files to git staging area
        #[arg(long = "git-add")]
        git_add: bool,
    },
}

#[derive(Deserialize, Debug)]
struct PostToolUseInput {
    session_id: String,
    transcript_path: String,
    tool_name: String,
    tool_input: serde_json::Value,
    tool_response: serde_json::Value,
}

/// Find a git repository directory by looking for .git
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(&current_dir)
                .context("No git repository found in current directory or parent directories")?
        }
    };
    
    // Create .claude/ccss_sessions directory
    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")?;
    
    // Create .gitkeep file
    let gitkeep_path = ccss_dir.join(".gitkeep");
    std::fs::write(&gitkeep_path, "")
        .context("Failed to create .gitkeep file")?;
    
    // Handle .gitattributes for Git LFS
    let gitattributes_path = repo_dir.join(".gitattributes");
    let lfs_line = ".claude/ccss_sessions/** filter=lfs diff=lfs merge=lfs -text";
    
    // Check if .gitattributes exists and read its content
    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")?;
        
        // Check if LFS line already exists
        if content.lines().any(|line| line.trim() == lfs_line) {
            println!("Git LFS configuration already exists in .gitattributes");
        } else {
            // Check if we need a newline before appending
            needs_newline = !content.is_empty() && !content.ends_with('\n');
        }
    }
    
    // Add LFS line if it doesn't exist
    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 hook_command(repo_dir: Option<PathBuf>, git_add: bool) -> Result<()> {
    // Initialize filesystem
    let filesystem = RealFileSystem::new();
    
    // Read JSON from stdin
    let mut buffer = String::new();
    std::io::stdin().read_to_string(&mut buffer)
        .context("Failed to read from stdin")?;
    
    // Parse JSON
    let input: PostToolUseInput = serde_json::from_str(&buffer)
        .context("Failed to parse PostToolUse JSON")?;
    
    hook_command_with_filesystem(input, repo_dir, git_add, &filesystem)
}

fn hook_command_with_filesystem<FS: FileSystem>(
    input: PostToolUseInput,
    repo_dir: Option<PathBuf>,
    git_add: bool,
    filesystem: &FS,
) -> Result<()> {
    log::info!("Processing PostToolUse hook for session: {}", input.session_id);
    log::info!("Tool: {}", input.tool_name);
    log::info!("Transcript path: {}", input.transcript_path);
    
    // Determine repository directory
    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(&current_dir)
                .context("No git repository found in current directory or parent directories")?
        }
    };
    
    // Parse the transcript path to extract the relative path structure
    let transcript_path = PathBuf::from(&input.transcript_path);
    let transcript_path = if transcript_path.starts_with("~") {
        // Expand tilde to home directory
        let home = dirs::home_dir()
            .context("Failed to get home directory")?;
        home.join(transcript_path.strip_prefix("~").unwrap())
    } else {
        transcript_path
    };
    
    // Extract the relative path from ~/.claude/projects/
    let claude_projects_dir = dirs::home_dir()
        .context("Failed to get home directory")?
        .join(".claude")
        .join("projects");
    
    
    let relative_path = transcript_path.strip_prefix(&claude_projects_dir)
        .context("Transcript path is not under ~/.claude/projects/")?;
    
    // Get the Claude Code style project name for the current repository
    let project_name = dir_path_to_claude_code_stype(repo_dir.clone())
        .context("Failed to convert repository path to Claude Code style")?;
    
    log::info!("Using Claude Code project name: {}", project_name);
    
    // Extract just the filename from the transcript path
    let file_name = transcript_path.file_name()
        .context("No filename in transcript path")?;
    
    // Construct target path: .claude/ccss_sessions/project_name/file_name
    let target_path = repo_dir
        .join(".claude")
        .join("ccss_sessions")
        .join(&project_name)
        .join(file_name);
    
    // Copy the file (parent directories are created automatically by copy_file)
    filesystem.copy_file(&transcript_path, &target_path)
        .context("Failed to copy transcript file")?;
    
    log::info!("Successfully copied {} to {}", transcript_path.display(), target_path.display());
    println!("Synced transcript: {}", relative_path.display());
    
    // Add to git if requested
    if git_add {
        let repo = git2::Repository::open(&repo_dir)
            .context("Failed to open git repository")?;
        
        let mut index = repo.index()
            .context("Failed to get git index")?;
        
        // Convert target_path to relative path from repo root
        let rel_path = target_path.strip_prefix(&repo_dir)
            .context("Failed to get relative path")?;
        
        index.add_path(rel_path)
            .context("Failed to add file to git index")?;
        
        index.write()
            .context("Failed to write git index")?;
        
        log::info!("Added {} to git staging area", rel_path.display());
        println!("Added to git: {}", rel_path.display());
    }
    
    Ok(())
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    
    // Initialize logger
    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::Hook { repo_dir, git_add } => hook_command(repo_dir, git_add),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use cc_sync_session::mock::MockFileSystem;
    use std::time::SystemTime;
    use tempfile::TempDir;
    use serde_json::json;

    #[test]
    fn test_hook_command_with_filesystem() {
        // Create a temporary directory to act as the repository
        let temp_dir = TempDir::new().unwrap();
        let repo_path = temp_dir.path().to_path_buf();
        
        // Create .git directory to make it look like a git repo
        std::fs::create_dir_all(repo_path.join(".git")).unwrap();
        
        // Create mock filesystem
        let mock_fs = MockFileSystem::new();
        
        // Set up source file in mock filesystem
        let home_dir = dirs::home_dir().unwrap();
        let source_path = home_dir
            .join(".claude")
            .join("projects")
            .join(format!("-{}", repo_path.to_string_lossy().replace('/', "-").trim_start_matches('-')))
            .join("test-session.jsonl");
        
        mock_fs.add_file(
            &source_path,
            b"{\"test\": \"content\"}".to_vec(),
            SystemTime::now(),
        );
        
        // Create input
        let input = PostToolUseInput {
            session_id: "test123".to_string(),
            transcript_path: format!("~/.claude/projects/-{}/test-session.jsonl", 
                repo_path.to_string_lossy().replace('/', "-").trim_start_matches('-')),
            tool_name: "Write".to_string(),
            tool_input: serde_json::json!({"file_path": "/test/file.txt", "content": "test content"}),
            tool_response: serde_json::json!({"filePath": "/test/file.txt", "success": true}),
        };
        
        // Run the command
        let result = hook_command_with_filesystem(input, Some(repo_path.clone()), false, &mock_fs);
        
        // Check result
        assert!(result.is_ok(), "Command failed: {:?}", result);
        
        // Verify file was copied
        let project_name_for_test = format!("-{}", repo_path.to_string_lossy().replace('/', "-").trim_start_matches('-'));
        let expected_target = repo_path
            .join(".claude")
            .join("ccss_sessions")
            .join(&project_name_for_test)
            .join("test-session.jsonl");
        
        let copied_content = mock_fs.get_file_content(&expected_target);
        assert!(copied_content.is_some(), "File was not copied to expected location");
        assert_eq!(copied_content.unwrap(), b"{\"test\": \"content\"}");
    }
    
    #[test]
    fn test_hook_command_with_nested_path() {
        let temp_dir = TempDir::new().unwrap();
        let repo_path = temp_dir.path().to_path_buf();
        
        std::fs::create_dir_all(repo_path.join(".git")).unwrap();
        
        let mock_fs = MockFileSystem::new();
        
        // Set up source file with nested path
        let home_dir = dirs::home_dir().unwrap();
        let source_path = home_dir
            .join(".claude")
            .join("projects")
            .join(format!("-{}", repo_path.to_string_lossy().replace('/', "-").trim_start_matches('-')))
            .join("nested")
            .join("path")
            .join("session.jsonl");
        
        mock_fs.add_file(
            &source_path,
            b"{\"nested\": \"data\"}".to_vec(),
            SystemTime::now(),
        );
        
        let input = PostToolUseInput {
            session_id: "test456".to_string(),
            transcript_path: format!("~/.claude/projects/-{}/nested/path/session.jsonl", 
                repo_path.to_string_lossy().replace('/', "-").trim_start_matches('-')),
            tool_name: "Edit".to_string(),
            tool_input: serde_json::json!({}),
            tool_response: serde_json::json!({}),
        };
        
        let result = hook_command_with_filesystem(input, Some(repo_path.clone()), false, &mock_fs);
        
        assert!(result.is_ok(), "Command failed: {:?}", result);
        
        // Verify file was copied with nested path structure
        let project_name_for_test = format!("-{}", repo_path.to_string_lossy().replace('/', "-").trim_start_matches('-'));
        let expected_target = repo_path
            .join(".claude")
            .join("ccss_sessions")
            .join(&project_name_for_test)
            .join("session.jsonl");
        
        let copied_content = mock_fs.get_file_content(&expected_target);
        assert!(copied_content.is_some(), "File was not copied to expected location");
        assert_eq!(copied_content.unwrap(), b"{\"nested\": \"data\"}");
    }
}