Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions crates/pcr-core/src/commands/project_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -89,7 +89,3 @@ pub fn load_proj_by_id() -> std::collections::BTreeMap<String, String> {
}
m
}

pub fn _unused_project(p: &Project) -> &Project {
p
}
6 changes: 5 additions & 1 deletion crates/pcr-core/src/commands/push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 15 additions & 2 deletions crates/pcr-core/src/commands/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")));
}
}
Expand All @@ -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}")));
}
}
Expand Down
54 changes: 53 additions & 1 deletion crates/pcr-core/src/sources/shared/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,32 @@ 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 {
combined
}
}

/// 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
Expand Down Expand Up @@ -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];
}
}
35 changes: 28 additions & 7 deletions crates/pcr-core/src/sources/vscode/watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (`<hash>/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);
Expand Down
21 changes: 17 additions & 4 deletions crates/pcr-core/src/sources/vscode/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
/// 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`).
Expand Down Expand Up @@ -59,8 +63,17 @@ pub fn scan_workspaces() -> Vec<WorkspaceMatch> {
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,
Expand Down
21 changes: 11 additions & 10 deletions crates/pcr-core/src/store/diff_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,24 @@ pub fn get_diff_events_in_window(
) -> Result<Vec<DiffEvent>> {
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::<rusqlite::Result<Vec<_>>>()?;
(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::<rusqlite::Result<Vec<_>>>()?;
(sql, rows)
v
};
let _ = sql;
Ok(rows)
}

Expand Down
60 changes: 59 additions & 1 deletion crates/pcr-core/src/supabase/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
Loading