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,
#[arg(short, long, global = true)]
verbose: bool,
}
#[derive(Subcommand, Debug)]
enum Commands {
Init {
#[arg(short = 'r', long)]
repo_dir: Option<PathBuf>,
},
Hook {
#[arg(short = 'r', long)]
repo_dir: Option<PathBuf>,
#[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,
}
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 hook_command(repo_dir: Option<PathBuf>, git_add: bool) -> Result<()> {
let filesystem = RealFileSystem::new();
let mut buffer = String::new();
std::io::stdin().read_to_string(&mut buffer)
.context("Failed to read from stdin")?;
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);
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 transcript_path = PathBuf::from(&input.transcript_path);
let transcript_path = if transcript_path.starts_with("~") {
let home = dirs::home_dir()
.context("Failed to get home directory")?;
home.join(transcript_path.strip_prefix("~").unwrap())
} else {
transcript_path
};
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/")?;
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);
let file_name = transcript_path.file_name()
.context("No filename in transcript path")?;
let target_path = repo_dir
.join(".claude")
.join("ccss_sessions")
.join(&project_name)
.join(file_name);
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());
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")?;
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();
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() {
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();
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(),
);
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}),
};
let result = hook_command_with_filesystem(input, Some(repo_path.clone()), false, &mock_fs);
assert!(result.is_ok(), "Command failed: {:?}", result);
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();
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);
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\"}");
}
}