diff --git a/crates/pcr-core/src/commands/project_context.rs b/crates/pcr-core/src/commands/project_context.rs index 0ab680a..8786df1 100644 --- a/crates/pcr-core/src/commands/project_context.rs +++ b/crates/pcr-core/src/commands/project_context.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; -use crate::projects::{self, Project}; +use crate::projects; use crate::sources::shared::git; #[derive(Debug, Default, Clone)] @@ -89,7 +89,3 @@ pub fn load_proj_by_id() -> std::collections::BTreeMap { } m } - -pub fn _unused_project(p: &Project) -> &Project { - p -} diff --git a/crates/pcr-core/src/commands/push.rs b/crates/pcr-core/src/commands/push.rs index bb62cec..e6e4346 100644 --- a/crates/pcr-core/src/commands/push.rs +++ b/crates/pcr-core/src/commands/push.rs @@ -654,7 +654,11 @@ fn diff_file_header(section: &str) -> &str { fn truncate_diff(diff: &str) -> String { const MAX: usize = 50_000; if diff.len() > MAX { - let mut out = diff[..MAX].to_string(); + // Avoid panicking when MAX lands inside a multi-byte UTF-8 + // codepoint (non-ASCII filenames, accented identifiers in a + // hunk, …). Walk back to the nearest char boundary first. + let cut = crate::sources::shared::git::floor_char_boundary(diff, MAX); + let mut out = diff[..cut].to_string(); out.push_str("\n[truncated]"); out } else { diff --git a/crates/pcr-core/src/commands/show.rs b/crates/pcr-core/src/commands/show.rs index f611d36..ff85344 100644 --- a/crates/pcr-core/src/commands/show.rs +++ b/crates/pcr-core/src/commands/show.rs @@ -188,7 +188,16 @@ pub fn run(mode: OutputMode, args: ShowArgs) -> ExitCode { display::cstr(Color::Cyan, "CHANGED FILES") )); for f in arr { - let short = short_file_path(&format!("{}", f), &proj_by_id); + // `Value::Display` for a JSON string emits the + // surrounding quotes (e.g. `"src/foo.rs"`). Strip + // them via `as_str` so the path renders bare; fall + // back to the JSON form for anything that isn't a + // string so legacy non-string entries still surface. + let raw = f + .as_str() + .map(str::to_string) + .unwrap_or_else(|| f.to_string()); + let short = short_file_path(&raw, &proj_by_id); display::eprintln(&display::cstr(Color::Dim, &format!(" {short}"))); } } @@ -213,7 +222,11 @@ pub fn run(mode: OutputMode, args: ShowArgs) -> ExitCode { display::cstr(Color::Gray, "FILES IN CONTEXT") )); for f in arr { - let short = short_file_path(&format!("{}", f), &proj_by_id); + let raw = f + .as_str() + .map(str::to_string) + .unwrap_or_else(|| f.to_string()); + let short = short_file_path(&raw, &proj_by_id); display::eprintln(&display::cstr(Color::Dim, &format!(" {short}"))); } } diff --git a/crates/pcr-core/src/sources/shared/git.rs b/crates/pcr-core/src/sources/shared/git.rs index 59be7f0..a700d47 100644 --- a/crates/pcr-core/src/sources/shared/git.rs +++ b/crates/pcr-core/src/sources/shared/git.rs @@ -96,7 +96,11 @@ pub fn get_git_diff(project_path: &str) -> String { } const MAX: usize = 50_000; if combined.len() > MAX { - let mut out = combined[..MAX].to_string(); + // Truncate on a char boundary; raw byte slicing panics when the + // ceiling lands inside a multi-byte sequence (UTF-8 source + // file, non-ASCII in a diff hunk). + let cut = floor_char_boundary(&combined, MAX); + let mut out = combined[..cut].to_string(); out.push_str("\n[truncated]"); out } else { @@ -104,6 +108,20 @@ pub fn get_git_diff(project_path: &str) -> String { } } +/// Largest char-boundary index `<= idx` in `s`. Re-implementation of +/// the unstable `str::floor_char_boundary` so we can keep using stable +/// Rust on the workspace toolchain. +pub(crate) fn floor_char_boundary(s: &str, idx: usize) -> usize { + if idx >= s.len() { + return s.len(); + } + let mut i = idx; + while i > 0 && !s.is_char_boundary(i) { + i -= 1; + } + i +} + fn untracked_diff(project_path: &str) -> String { // NUL-terminated porcelain so filenames with spaces, quotes, or // newlines round-trip verbatim. Without `-z`, git shell-escapes @@ -218,3 +236,37 @@ pub fn git_output(args: &[&str]) -> String { pub fn git_output_in(dir: &str, args: &[&str]) -> String { run(args, Some(dir)) } + +#[cfg(test)] +mod tests { + use super::floor_char_boundary; + + #[test] + fn floor_char_boundary_handles_ascii() { + assert_eq!(floor_char_boundary("hello world", 5), 5); + assert_eq!(floor_char_boundary("hello world", 100), 11); + } + + #[test] + fn floor_char_boundary_walks_back_past_multibyte() { + // `é` is 2 bytes (0xC3 0xA9) in UTF-8 — slicing at the middle + // byte (index 5) of "caf\u{00e9}" would panic; we expect to + // walk back to byte 3 (boundary right before `é`). + let s = "café"; // bytes: c a f 0xC3 0xA9 → len 5 + assert_eq!(s.len(), 5); + assert_eq!(floor_char_boundary(s, 4), 3); + } + + #[test] + fn floor_char_boundary_walks_back_from_mid_emoji() { + let s = "ab🦀cd"; // bytes: a b 🦀(4) c d → len 8 + assert_eq!(s.len(), 8); + // Byte 6 is the start of `c` — already a boundary, leave alone. + assert_eq!(floor_char_boundary(s, 6), 6); + // Byte 4 lands inside the 🦀 sequence — walk back to byte 2. + let cut = floor_char_boundary(s, 4); + assert_eq!(cut, 2); + // Slicing must not panic. + let _ = &s[..cut]; + } +} diff --git a/crates/pcr-core/src/sources/vscode/watcher.rs b/crates/pcr-core/src/sources/vscode/watcher.rs index 1bf1089..e3a359d 100644 --- a/crates/pcr-core/src/sources/vscode/watcher.rs +++ b/crates/pcr-core/src/sources/vscode/watcher.rs @@ -67,7 +67,9 @@ pub fn run(user_id: &str, _dir: &Path) { return; }; for ws in workspaces.iter() { - watch_transcript_dir(&mut watcher, &ws.transcript_dir, &state); + if let Some(td) = ws.transcript_dir.as_ref() { + watch_transcript_dir(&mut watcher, td, &state); + } watch_transcript_dir(&mut watcher, &ws.chat_sessions_dir, &state); } } @@ -186,15 +188,21 @@ fn rescan_workspaces( if workspaces.iter().any(|w| w.hash == nm.hash) { // Workspace already known, but its chatSessions/transcripts // dir may have appeared since the last scan \u2014 re-watch is - // idempotent so just call it again. - watch_transcript_dir(watcher, &nm.transcript_dir, state); + // idempotent so just call it again. `transcript_dir` is + // `None` once the modern `chatSessions/` dir exists; in that + // case we don't add a legacy watch so we don't dual-capture. + if let Some(td) = nm.transcript_dir.as_ref() { + watch_transcript_dir(watcher, td, state); + } watch_transcript_dir(watcher, &nm.chat_sessions_dir, state); continue; } let td = nm.transcript_dir.clone(); let cs = nm.chat_sessions_dir.clone(); workspaces.push(nm); - watch_transcript_dir(watcher, &td, state); + if let Some(td) = td.as_ref() { + watch_transcript_dir(watcher, td, state); + } watch_transcript_dir(watcher, &cs, state); } } @@ -281,7 +289,9 @@ fn handle_new_dir( let td = nm.transcript_dir.clone(); let cs = nm.chat_sessions_dir.clone(); workspaces.push(nm); - watch_transcript_dir(watcher, &td, state); + if let Some(td) = td.as_ref() { + watch_transcript_dir(watcher, td, state); + } watch_transcript_dir(watcher, &cs, state); } } @@ -314,6 +324,15 @@ fn process_file( let lines = count_non_empty_lines(&bytes); let prev = state.get(file_path); let is_chat_sessions = file_path.contains("chatSessions"); + // Belt-and-braces guard for the dual-watch upgrade window: if a + // notify watch on the legacy `transcripts/` dir was registered + // before `chatSessions/` appeared (e.g. user upgraded VS Code + // mid-session), skip the legacy parser as soon as the modern dir + // exists. The chatsession parser owns dedup for that session + // going forward. + if !is_chat_sessions && ws.chat_sessions_dir.is_dir() { + return; + } // Legacy transcripts are append-only — short-circuit when no new // lines have arrived. The new chatSessions format rewrites the // file in place (kind=0 snapshot can collapse many ops), so the @@ -578,8 +597,10 @@ fn find_workspace<'a>( for ws in workspaces { // The hash_dir is the common ancestor of both `transcripts/` and // `chatSessions/` — match against it so either layout resolves to - // the same workspace entry. - let hash_dir = ws.transcript_dir.parent().and_then(|p| p.parent()); + // the same workspace entry. `chat_sessions_dir` is always + // populated (`/chatSessions`); its parent is the hash_dir + // regardless of whether the directory itself exists yet. + let hash_dir = ws.chat_sessions_dir.parent(); if let Some(hash_dir) = hash_dir { if file_path.starts_with(hash_dir.to_string_lossy().as_ref()) { return Some(ws); diff --git a/crates/pcr-core/src/sources/vscode/workspace.rs b/crates/pcr-core/src/sources/vscode/workspace.rs index 6aaefb9..c19a7be 100644 --- a/crates/pcr-core/src/sources/vscode/workspace.rs +++ b/crates/pcr-core/src/sources/vscode/workspace.rs @@ -9,9 +9,13 @@ use crate::projects::{self, Project}; #[derive(Debug, Clone)] pub struct WorkspaceMatch { pub hash: String, - /// Legacy `GitHub.copilot-chat/transcripts/` directory. Kept for - /// older Copilot Chat versions that still use it. - pub transcript_dir: PathBuf, + /// Legacy `GitHub.copilot-chat/transcripts/` directory. `None` when + /// the modern `chatSessions/` directory is also present on disk — + /// VS Code Copilot Chat 0.45+ writes both locations during the + /// upgrade window, and watching both paths produces duplicate + /// captures (different `captured_at` formatting → different + /// content hash → no dedup). We prefer the modern format. + pub transcript_dir: Option, /// New `chatSessions/` directory introduced in vscode 1.117 / /// copilot-chat 0.45+. Holds the actual conversation transcripts /// in a CRDT-style JSONL format (see `chatsession_parser`). @@ -59,8 +63,17 @@ pub fn scan_workspaces() -> Vec { if matched.is_empty() { continue; } - let transcript_dir = hash_dir.join("GitHub.copilot-chat").join("transcripts"); + let legacy_transcript_dir = hash_dir.join("GitHub.copilot-chat").join("transcripts"); let chat_sessions_dir = hash_dir.join("chatSessions"); + // Prefer the modern `chatSessions/` layout when it exists. + // Returning `None` here stops the watcher from registering a + // notify watch on the legacy `transcripts/` directory and + // dispatching the same prompt through both parsers. + let transcript_dir = if chat_sessions_dir.is_dir() { + None + } else { + Some(legacy_transcript_dir) + }; matches.push(WorkspaceMatch { hash: entry.file_name().to_string_lossy().into_owned(), transcript_dir, diff --git a/crates/pcr-core/src/store/diff_events.rs b/crates/pcr-core/src/store/diff_events.rs index a6e206c..db90d0c 100644 --- a/crates/pcr-core/src/store/diff_events.rs +++ b/crates/pcr-core/src/store/diff_events.rs @@ -44,23 +44,24 @@ pub fn get_diff_events_in_window( ) -> Result> { let conn = open(); let to_s = to.to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let (sql, rows) = if let Some(from) = from { + let rows = if let Some(from) = from { let from_s = from.to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let sql = "SELECT id, project_id, project_name, files, occurred_at FROM diff_events WHERE occurred_at > ? AND occurred_at <= ? ORDER BY occurred_at ASC"; - let mut stmt = conn.prepare(sql)?; - let rows = stmt + let mut stmt = conn.prepare( + "SELECT id, project_id, project_name, files, occurred_at FROM diff_events WHERE occurred_at > ? AND occurred_at <= ? ORDER BY occurred_at ASC", + )?; + let v: Vec<_> = stmt .query_map(params![from_s, to_s], map_diff_event)? .collect::>>()?; - (sql, rows) + v } else { - let sql = "SELECT id, project_id, project_name, files, occurred_at FROM diff_events WHERE occurred_at <= ? ORDER BY occurred_at ASC"; - let mut stmt = conn.prepare(sql)?; - let rows = stmt + let mut stmt = conn.prepare( + "SELECT id, project_id, project_name, files, occurred_at FROM diff_events WHERE occurred_at <= ? ORDER BY occurred_at ASC", + )?; + let v: Vec<_> = stmt .query_map(params![to_s], map_diff_event)? .collect::>>()?; - (sql, rows) + v }; - let _ = sql; Ok(rows) } diff --git a/crates/pcr-core/src/supabase/mod.rs b/crates/pcr-core/src/supabase/mod.rs index d4aa315..cec1c6f 100644 --- a/crates/pcr-core/src/supabase/mod.rs +++ b/crates/pcr-core/src/supabase/mod.rs @@ -5,6 +5,7 @@ //! correctly against rows written by previous Go builds. use anyhow::{anyhow, Result}; +use chrono::SecondsFormat; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -117,10 +118,35 @@ pub fn prompt_content_hash(session_id: &str, prompt_text: &str, response_text: & sha256_hex(&format!("{session_id}\x00{prompt_text}\x00{response_text}")) } +/// Normalise an ISO-8601 / RFC-3339 timestamp to a single canonical form +/// (UTC, millisecond precision, trailing `Z`) before hashing so two +/// captures of the same prompt that differ only in timestamp formatting +/// — e.g. `…23.123Z` vs `…23.123456Z` vs `…23.123+00:00` — produce the +/// same `content_hash` and dedupe against the live unique constraint. +/// +/// Falls back to the raw string when parsing fails so any pre-existing +/// hashes computed over non-ISO `captured_at` values stay stable. +fn normalize_captured_at(captured_at: &str) -> String { + match chrono::DateTime::parse_from_rfc3339(captured_at) { + Ok(dt) => dt + .with_timezone(&chrono::Utc) + .to_rfc3339_opts(SecondsFormat::Millis, true), + Err(_) => captured_at.to_string(), + } +} + /// V2 hash: includes `captured_at` instead of the response so two identical /// prompts sent at different times in the same session get distinct IDs. +/// +/// The captured_at value is normalised first (see `normalize_captured_at`) +/// because the legacy `transcripts/` parser keeps VS Code's raw timestamp +/// string while the modern `chatSessions/` parser reformats via +/// `SecondsFormat::Millis`. Without normalisation those two paths hash +/// differently for the same exchange and bypass the unique constraint +/// on `prompts.content_hash`. pub fn prompt_content_hash_v2(session_id: &str, prompt_text: &str, captured_at: &str) -> String { - sha256_hex(&format!("{session_id}\x00{prompt_text}\x00{captured_at}")) + let normalized = normalize_captured_at(captured_at); + sha256_hex(&format!("{session_id}\x00{prompt_text}\x00{normalized}")) } pub fn prompt_id(session_id: &str, prompt_text: &str, response_text: &str) -> String { @@ -417,6 +443,38 @@ mod tests { ); } + #[test] + fn v2_hash_normalizes_captured_at_format() { + // Same instant, different RFC-3339 spellings: VS Code's legacy + // transcripts/ parser keeps the raw timestamp string while the + // chatSessions/ parser reformats via SecondsFormat::Millis. + // Both paths must produce the same hash so the unique constraint + // on prompts.content_hash dedups them. + let millis_z = prompt_content_hash_v2("s", "p", "2026-05-11T10:52:23.123Z"); + let micros_z = prompt_content_hash_v2("s", "p", "2026-05-11T10:52:23.123456Z"); + let plus_zero = prompt_content_hash_v2("s", "p", "2026-05-11T10:52:23.123+00:00"); + let plus_offset = prompt_content_hash_v2("s", "p", "2026-05-11T12:52:23.123+02:00"); + assert_eq!(millis_z, micros_z); + assert_eq!(millis_z, plus_zero); + assert_eq!(millis_z, plus_offset); + // Same applies to the UUID-shape v2 id. + assert_eq!( + prompt_id_v2("s", "p", "2026-05-11T10:52:23.123Z"), + prompt_id_v2("s", "p", "2026-05-11T10:52:23.123456Z"), + ); + + // A meaningfully different timestamp must still hash differently. + let later = prompt_content_hash_v2("s", "p", "2026-05-11T10:52:24.000Z"); + assert_ne!(millis_z, later); + + // Unparseable strings preserve the legacy "raw bytes in, raw + // bytes hashed" behaviour so we don't silently break any + // pre-existing v2 hashes computed over non-RFC3339 inputs. + let raw_a = prompt_content_hash_v2("s", "p", "not-a-timestamp"); + let raw_b = prompt_content_hash_v2("s", "p", "not-a-timestamp "); + assert_ne!(raw_a, raw_b); + } + #[test] fn prompt_id_is_uuid_shape() { let id = prompt_id("a", "b", "c");