Skip to content

Commit 33ed5aa

Browse files
authored
Merge pull request #10352 from gitbutlerapp/kv-branch-55
Support for cursor hooks
2 parents 4aeea74 + a3a0103 commit 33ed5aa

File tree

10 files changed

+700
-7
lines changed

10 files changed

+700
-7
lines changed

Cargo.lock

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ but-tools = { path = "crates/but-tools" }
8787
but-api = { path = "crates/but-api" }
8888
but-api-macros = { path = "crates/but-api-macros" }
8989
but-claude = { path = "crates/but-claude" }
90+
but-cursor = { path = "crates/but-cursor" }
9091
but-broadcaster = { path = "crates/but-broadcaster" }
9192
git2-hooks = { version = "0.5.0" }
9293
itertools = "0.14.0"

crates/but-claude/src/hooks/mod.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ pub async fn handle_stop() -> anyhow::Result<ClaudeHookOutput> {
171171
for branch in &outcome.updated_branches {
172172
let mut commit_message_mapping = HashMap::new();
173173

174-
let elegibility = is_branch_eligible_for_rename(&defer, &stacks, branch)?;
174+
let elegibility = is_branch_eligible_for_rename(defer.ctx, &stacks, branch)?;
175175

176176
for commit in &branch.new_commits {
177177
if let Ok(commit_id) = gix::ObjectId::from_str(commit) {
@@ -232,7 +232,7 @@ pub async fn handle_stop() -> anyhow::Result<ClaudeHookOutput> {
232232
})
233233
}
234234

235-
enum RenameEligibility {
235+
pub enum RenameEligibility {
236236
Eligible { commit_id: gix::ObjectId },
237237
NotEligible,
238238
}
@@ -248,8 +248,8 @@ enum RenameEligibility {
248248
/// The intention behind this implementation is to ensure that the more costly operation (getting the stack details)
249249
/// is only performed if necessary.
250250
/// This is determined by first checking if the newly added commits are only one and the branch tip matches the commit ID.
251-
fn is_branch_eligible_for_rename(
252-
defer: &ClearLocksGuard<'_>,
251+
pub fn is_branch_eligible_for_rename(
252+
ctx: &CommandContext,
253253
stacks: &[but_workspace::ui::StackEntry],
254254
branch: &but_action::UpdatedBranch,
255255
) -> Result<RenameEligibility, anyhow::Error> {
@@ -278,7 +278,7 @@ fn is_branch_eligible_for_rename(
278278
}
279279

280280
// Get stack details and branch details
281-
let details = stack_details(defer.ctx, stack_entry.id.context("BUG(opt-stack-id)")?)?;
281+
let details = stack_details(ctx, stack_entry.id.context("BUG(opt-stack-id)")?)?;
282282
let branch_details = details
283283
.branch_details
284284
.iter()
@@ -451,7 +451,7 @@ fn original_session_id(ctx: &mut CommandContext, current_id: String) -> Result<S
451451
}
452452
}
453453

454-
fn get_or_create_session(
454+
pub fn get_or_create_session(
455455
ctx: &mut CommandContext,
456456
session_id: &str,
457457
stacks: Vec<but_workspace::ui::StackEntry>,

crates/but-cursor/Cargo.toml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[package]
2+
name = "but-cursor"
3+
version = "0.0.0"
4+
edition = "2024"
5+
authors = ["GitButler <[email protected]>"]
6+
publish = false
7+
8+
[dependencies]
9+
anyhow.workspace = true
10+
serde.workspace = true
11+
gitbutler-project.workspace = true
12+
but-workspace.workspace = true
13+
but-claude.workspace = true # Move out get_or_create_session and remove this dependency
14+
gitbutler-command-context.workspace = true
15+
but-settings.workspace = true
16+
but-graph.workspace = true
17+
but-action.workspace = true
18+
but-hunk-assignment.workspace = true
19+
md5 = "0.8.0"
20+
rand = "0.9.0"
21+
diesel = { version = "2.2.12", features = ["sqlite"] }
22+
but-core.workspace = true
23+
gitbutler-stack.workspace = true
24+
serde_json = "1.0.143"
25+
gix = { workspace = true, features = [] }

crates/but-cursor/src/db.rs

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
use crate::workspace_identifier::get_single_folder_workspace_identifier;
2+
use anyhow::Result;
3+
use diesel::prelude::*;
4+
use diesel::sql_query;
5+
use diesel::sql_types::Text;
6+
use serde::{Deserialize, Serialize};
7+
use std::path::Path;
8+
9+
#[derive(Debug, Clone, Serialize, Deserialize)]
10+
pub struct Generation {
11+
#[serde(rename = "generationUUID")]
12+
pub generation_uuid: String,
13+
#[serde(rename = "textDescription")]
14+
pub text_description: String,
15+
#[serde(rename = "type")]
16+
pub generation_type: String,
17+
#[serde(rename = "unixMs")]
18+
pub unix_ms: i64,
19+
}
20+
21+
/// Get the base directory for Cursor workspace storage based on the platform
22+
fn get_cursor_base_dir(nightly: bool) -> Result<std::path::PathBuf> {
23+
let cursor_name = if nightly { "Cursor Nightly" } else { "Cursor" };
24+
25+
#[cfg(target_os = "windows")]
26+
{
27+
let appdata = std::env::var("APPDATA")
28+
.map_err(|_| anyhow::anyhow!("APPDATA environment variable not found"))?;
29+
Ok(std::path::PathBuf::from(appdata)
30+
.join(cursor_name)
31+
.join("User")
32+
.join("workspaceStorage"))
33+
}
34+
35+
#[cfg(target_os = "macos")]
36+
{
37+
let home = std::env::var("HOME")
38+
.map_err(|_| anyhow::anyhow!("HOME environment variable not found"))?;
39+
Ok(std::path::PathBuf::from(home)
40+
.join("Library")
41+
.join("Application Support")
42+
.join(cursor_name)
43+
.join("User")
44+
.join("workspaceStorage"))
45+
}
46+
47+
#[cfg(target_os = "linux")]
48+
{
49+
let config_dir = std::env::var("XDG_CONFIG_HOME")
50+
.map(std::path::PathBuf::from)
51+
.unwrap_or_else(|_| {
52+
let home = std::env::var("HOME").unwrap_or_default();
53+
std::path::PathBuf::from(home).join(".config")
54+
});
55+
Ok(config_dir
56+
.join(cursor_name)
57+
.join("User")
58+
.join("workspaceStorage"))
59+
}
60+
61+
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
62+
{
63+
anyhow::bail!("Unsupported platform");
64+
}
65+
}
66+
67+
/// Get the path to the Cursor database file for the given repository
68+
fn get_cursor_db_path(repo_path: &Path, nightly: bool) -> Result<std::path::PathBuf> {
69+
let base_dir = get_cursor_base_dir(nightly)?;
70+
let workspace_id = get_single_folder_workspace_identifier(repo_path)?;
71+
72+
Ok(base_dir.join(workspace_id).join("state.vscdb"))
73+
}
74+
75+
/// Parse the JSON value from the database into a Vec<Generation>
76+
fn parse_generations_json(json_str: &str) -> Result<Vec<Generation>> {
77+
let generations: Vec<Generation> = serde_json::from_str(json_str)
78+
.map_err(|e| anyhow::anyhow!("Failed to parse generations JSON: {}", e))?;
79+
Ok(generations)
80+
}
81+
82+
/// Result struct for SQL query
83+
#[derive(QueryableByName)]
84+
struct GenerationQueryResult {
85+
#[diesel(sql_type = Text)]
86+
value: String,
87+
}
88+
89+
/// Get AI service generations from the Cursor database for the given repository
90+
pub fn get_generations(repo_path: &Path, nightly: bool) -> Result<Vec<Generation>> {
91+
let db_path = get_cursor_db_path(repo_path, nightly)?;
92+
93+
if !db_path.exists() {
94+
return Ok(Vec::new());
95+
}
96+
97+
let db_url = format!("file:{}", db_path.to_string_lossy());
98+
let mut conn = SqliteConnection::establish(&db_url)
99+
.map_err(|e| anyhow::anyhow!("Failed to connect to database at {:?}: {}", db_path, e))?;
100+
101+
let query_result: Result<Vec<GenerationQueryResult>, diesel::result::Error> =
102+
sql_query("SELECT value FROM ItemTable WHERE key = ?")
103+
.bind::<Text, _>("aiService.generations")
104+
.load(&mut conn);
105+
106+
match query_result {
107+
Ok(results) => {
108+
if let Some(result) = results.first() {
109+
parse_generations_json(&result.value)
110+
} else {
111+
Ok(Vec::new()) // Key not found
112+
}
113+
}
114+
Err(e) => Err(anyhow::anyhow!("Database query failed: {}", e)),
115+
}
116+
}
117+
118+
#[cfg(test)]
119+
mod tests {
120+
use super::*;
121+
122+
#[test]
123+
fn test_get_cursor_base_dir_regular() {
124+
let result = get_cursor_base_dir(false);
125+
assert!(result.is_ok());
126+
let path = result.unwrap();
127+
128+
#[cfg(target_os = "macos")]
129+
assert!(
130+
path.to_string_lossy()
131+
.contains("Library/Application Support/Cursor")
132+
);
133+
134+
#[cfg(target_os = "windows")]
135+
assert!(path.to_string_lossy().contains("\\Cursor\\"));
136+
137+
#[cfg(target_os = "linux")]
138+
assert!(
139+
path.to_string_lossy().contains(".config/Cursor")
140+
|| path.to_string_lossy().contains("Cursor")
141+
);
142+
}
143+
144+
#[test]
145+
fn test_get_cursor_base_dir_nightly() {
146+
let result = get_cursor_base_dir(true);
147+
assert!(result.is_ok());
148+
let path = result.unwrap();
149+
assert!(path.to_string_lossy().contains("Cursor Nightly"));
150+
}
151+
152+
#[test]
153+
fn test_parse_generations_json() {
154+
let json_str = r#"[{
155+
"generationUUID": "ade2d936-9af0-457d-b16a-7293ec309f5f",
156+
"textDescription": "Add Esteban 6",
157+
"type": "composer",
158+
"unixMs": 1758115352488
159+
}]"#;
160+
161+
let result = parse_generations_json(json_str);
162+
if let Err(e) = &result {
163+
eprintln!("JSON parsing failed: {e}");
164+
}
165+
assert!(result.is_ok());
166+
167+
let generations = result.unwrap();
168+
assert_eq!(generations.len(), 1);
169+
170+
let generation = &generations[0];
171+
assert_eq!(
172+
generation.generation_uuid,
173+
"ade2d936-9af0-457d-b16a-7293ec309f5f"
174+
);
175+
assert_eq!(generation.text_description, "Add Esteban 6");
176+
assert_eq!(generation.generation_type, "composer");
177+
assert_eq!(generation.unix_ms, 1758115352488);
178+
}
179+
180+
#[test]
181+
fn test_get_cursor_db_path() {
182+
// Use current directory which should exist
183+
let repo_path = std::env::current_dir().unwrap();
184+
let result = get_cursor_db_path(&repo_path, false);
185+
if let Err(e) = &result {
186+
eprintln!("get_cursor_db_path failed: {e}");
187+
}
188+
assert!(result.is_ok());
189+
190+
let db_path = result.unwrap();
191+
assert!(db_path.to_string_lossy().ends_with("state.vscdb"));
192+
}
193+
194+
#[test]
195+
fn test_get_generations_nonexistent_db() {
196+
// Use current directory but the database file won't exist
197+
let repo_path = std::env::current_dir().unwrap();
198+
let result = get_generations(&repo_path, false);
199+
if let Err(e) = &result {
200+
eprintln!("get_generations failed: {e}");
201+
}
202+
assert!(result.is_ok());
203+
204+
let generations = result.unwrap();
205+
assert_eq!(generations.len(), 0);
206+
}
207+
}

0 commit comments

Comments
 (0)