Skip to content
Open
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,27 @@

All notable changes to the Toolpath workspace are documented here.

## toolpath-gemini 0.6.1: derive `project_path` from caller/on-disk identity, not the log's `directories` — 2026-07-08

Same class of bug as #103 (`toolpath-claude` 0.11.1): `read_session_metadata`
preferred the chat file's internal `directories()[0]` over the caller-passed
project path, so a session projected onto this machine from elsewhere (e.g.
`path resume` of a Pathbase upload) reported the original author's foreign
path in `ConversationMetadata.project_path` instead of the path it actually
lives under here.

Fix: new trust order, in `crates/toolpath-gemini/src/io.rs`. (1) If the
caller-passed `project_path` is absolute, use it verbatim — it's derived
from the on-disk project identity by every real caller (e.g.
`list_project_dirs`). (2) Only then fall back to the chat file's internal
`directories()[0]`, then to the caller string verbatim (preserves prior
behavior when nothing else resolves; a non-absolute caller string can't
actually reach metadata assembly today, since `PathResolver::project_dir`
never resolves a bare slot name to its own slot directory). Both
`project_root` call sites in `read_session_metadata` (main-file and
orphan-UUID-directory cases) now go through a shared
`resolve_display_project_path` helper. No public API change.

## Derive: resolve duplicate step ids — 2026-07-01

- **`toolpath-convo`** (0.11.1): `derive_path` now guarantees the derived
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ toolpath = { version = "0.7.0", path = "crates/toolpath" }
toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" }
toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" }
toolpath-claude = { version = "0.12.0", path = "crates/toolpath-claude", default-features = false }
toolpath-gemini = { version = "0.6.0", path = "crates/toolpath-gemini", default-features = false }
toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false }
toolpath-codex = { version = "0.6.0", path = "crates/toolpath-codex" }
toolpath-opencode = { version = "0.5.0", path = "crates/toolpath-opencode" }
toolpath-cursor = { version = "0.2.0", path = "crates/toolpath-cursor" }
Expand Down
2 changes: 1 addition & 1 deletion crates/toolpath-gemini/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "toolpath-gemini"
version = "0.6.0"
version = "0.6.1"
edition.workspace = true
license.workspace = true
repository = "https://github.com/empathic/toolpath"
Expand Down
80 changes: 69 additions & 11 deletions crates/toolpath-gemini/src/io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@ use crate::reader::ConversationReader;
use crate::types::{ChatFile, Conversation, ConversationMetadata, GeminiRole, LogEntry};
use std::path::PathBuf;

/// Resolve the display `project_path` for a session's metadata.
///
/// Trust order (mirrors the toolpath-claude #103 precedent — a
/// projected/foreign session must not be able to claim an arbitrary
/// `project_path` via its own log content):
///
/// 1. If `caller_project` is an absolute path, it wins verbatim — the
/// caller (typically derived from the on-disk project identity, e.g.
/// via `list_project_dirs`) is authoritative.
/// 2. Otherwise fall back to the chat file's internal `directories()[0]`,
/// then to `caller_project` verbatim (prior behavior, preserved for
/// back-compat when nothing else resolves).
///
/// Note that a non-absolute `caller_project` cannot actually reach this
/// function today: [`PathResolver::project_dir`] only resolves real
/// project paths (an exact `projects.json` key, or the SHA-256 of the
/// passed string — never `tmp/<arg>` verbatim), so a bare slot name fails
/// file resolution with `ConversationNotFound` before metadata assembly.
/// The fallback arm exists only to preserve the old behavior for that
/// shape.
fn resolve_display_project_path(caller_project: &str, chat: &ChatFile) -> String {
if std::path::Path::new(caller_project).is_absolute() {
return caller_project.to_string();
}
chat.directories()
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| caller_project.to_string())
}

/// First non-empty `"user"` prompt in a chat file, used as a session "title".
fn first_user_text(chat: &ChatFile) -> Option<String> {
chat.messages
Expand Down Expand Up @@ -246,11 +276,7 @@ impl ConvoIO {
.iter()
.filter(|c| c.kind.as_deref() == Some("subagent"))
.count();
let project_root: String = main
.directories()
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| project_path.to_string());
let project_root: String = resolve_display_project_path(project_path, &main);
let first_user_message = first_user_text(&main);
return Ok(ConversationMetadata {
session_uuid: session_id.to_string(),
Expand Down Expand Up @@ -287,12 +313,7 @@ impl ConvoIO {
.filter(|(_, c)| c.kind.as_deref() == Some("subagent"))
.count();

let project_root: String = main
.1
.directories()
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| project_path.to_string());
let project_root: String = resolve_display_project_path(project_path, &main.1);

let first_user_message = first_user_text(&main.1);

Expand Down Expand Up @@ -409,6 +430,43 @@ mod tests {
assert_eq!(convo.project_path.as_deref(), Some("/abs/myrepo"));
}

#[test]
fn test_read_session_metadata_trusts_caller_project_over_directories() {
// Mirrors the toolpath-claude #103 precedent
// (crates/toolpath-claude/src/reader.rs test
// `test_read_conversation_metadata`): the chat file's internal
// `directories` claims a foreign path, but the caller passed an
// absolute `project_path` — the caller's value must win.
// Hash-slot layout (no projects.json): the slot dir is the
// SHA-256 of the caller's project path, so file resolution works
// without any friendly-name mapping — keeping the fixture free of
// anything that could imply slot resolution is under test here.
let temp = TempDir::new().unwrap();
let gemini = temp.path().join(".gemini");
let slot = crate::paths::project_hash("/real/project");
let session_dir = gemini.join("tmp").join(&slot).join("chats/session-uuid");
fs::create_dir_all(&session_dir).unwrap();
let main = r#"{
"sessionId":"main-s",
"projectHash":"h",
"startTime":"2026-04-17T15:00:00Z",
"lastUpdated":"2026-04-17T15:10:00Z",
"directories":["/somewhere/else"],
"messages":[
{"id":"m1","timestamp":"2026-04-17T15:00:00Z","type":"user","content":[{"text":"Hello"}]}
]
}"#;
fs::write(session_dir.join("main.json"), main).unwrap();

let resolver = PathResolver::new().with_gemini_dir(&gemini);
let io = ConvoIO::with_resolver(resolver);

let meta = io
.read_session_metadata("/real/project", "session-uuid")
.unwrap();
assert_eq!(meta.project_path, "/real/project");
}

#[test]
fn test_read_session_metadata() {
let (_t, io) = setup();
Expand Down
2 changes: 1 addition & 1 deletion site/_data/crates.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
},
{
"name": "toolpath-gemini",
"version": "0.6.0",
"version": "0.6.1",
"description": "Derive from Gemini CLI conversation logs",
"docs": "https://docs.rs/toolpath-gemini",
"crate": "https://crates.io/crates/toolpath-gemini",
Expand Down
Loading