jettypod 4.4.118 → 4.4.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +145 -116
- package/lib/bdd-preflight.js +96 -0
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- package/docs/bdd-guidance.md +0 -390
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
//! Session management.
|
|
2
|
+
//!
|
|
3
|
+
//! Tracks active Claude Code sessions, their associated work items,
|
|
4
|
+
//! and provides session lifecycle operations.
|
|
5
|
+
//!
|
|
6
|
+
//! Three subsystems:
|
|
7
|
+
//! 1. **Claude sessions** (DB) — track which Claude sessions are active, their content
|
|
8
|
+
//! 2. **Session files** (filesystem) — `.claude/session.md` for mode/context resumption
|
|
9
|
+
//! 3. **Current work** — derive active work item from git branch name
|
|
10
|
+
|
|
11
|
+
use std::path::{Path, PathBuf};
|
|
12
|
+
|
|
13
|
+
use rusqlite::params;
|
|
14
|
+
|
|
15
|
+
use crate::db::Database;
|
|
16
|
+
use crate::error::{CoreError, Result, WorkError};
|
|
17
|
+
use crate::git;
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Claude Sessions (DB-backed)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/// A Claude Code session record.
|
|
24
|
+
#[derive(Debug, Clone)]
|
|
25
|
+
pub struct ClaudeSession {
|
|
26
|
+
pub id: i64,
|
|
27
|
+
pub work_item_id: Option<i64>,
|
|
28
|
+
pub title: String,
|
|
29
|
+
pub session_title: Option<String>,
|
|
30
|
+
pub status: SessionStatus,
|
|
31
|
+
pub started_at: String,
|
|
32
|
+
pub completed_at: Option<String>,
|
|
33
|
+
pub content: Option<String>,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Session lifecycle status.
|
|
37
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
38
|
+
pub enum SessionStatus {
|
|
39
|
+
Active,
|
|
40
|
+
Completed,
|
|
41
|
+
Error,
|
|
42
|
+
Orphaned,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
impl SessionStatus {
|
|
46
|
+
pub fn as_str(&self) -> &'static str {
|
|
47
|
+
match self {
|
|
48
|
+
Self::Active => "active",
|
|
49
|
+
Self::Completed => "completed",
|
|
50
|
+
Self::Error => "error",
|
|
51
|
+
Self::Orphaned => "orphaned",
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
impl std::fmt::Display for SessionStatus {
|
|
57
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
58
|
+
f.write_str(self.as_str())
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
impl std::str::FromStr for SessionStatus {
|
|
63
|
+
type Err = CoreError;
|
|
64
|
+
fn from_str(s: &str) -> Result<Self> {
|
|
65
|
+
match s {
|
|
66
|
+
"active" => Ok(Self::Active),
|
|
67
|
+
"completed" => Ok(Self::Completed),
|
|
68
|
+
"error" => Ok(Self::Error),
|
|
69
|
+
"orphaned" => Ok(Self::Orphaned),
|
|
70
|
+
_ => Err(WorkError::InvalidState(format!("invalid session status: {s}")).into()),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result<ClaudeSession> {
|
|
76
|
+
let status_str: String = row.get("status")?;
|
|
77
|
+
Ok(ClaudeSession {
|
|
78
|
+
id: row.get("id")?,
|
|
79
|
+
work_item_id: row.get("work_item_id")?,
|
|
80
|
+
title: row.get("title")?,
|
|
81
|
+
session_title: row.get("session_title")?,
|
|
82
|
+
status: status_str.parse().unwrap_or(SessionStatus::Active),
|
|
83
|
+
started_at: row.get("started_at")?,
|
|
84
|
+
completed_at: row.get("completed_at")?,
|
|
85
|
+
content: row.get("content").ok(),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Create a new session (unlinked to a work item).
|
|
90
|
+
pub fn create_session(db: &Database, title: &str, session_title: Option<&str>) -> Result<i64> {
|
|
91
|
+
db.conn().execute(
|
|
92
|
+
"INSERT INTO claude_sessions (title, session_title, status, started_at) \
|
|
93
|
+
VALUES (?, ?, 'active', datetime('now'))",
|
|
94
|
+
params![title, session_title],
|
|
95
|
+
)?;
|
|
96
|
+
Ok(db.conn().last_insert_rowid())
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Get a session by id.
|
|
100
|
+
pub fn get_session(db: &Database, id: i64) -> Result<Option<ClaudeSession>> {
|
|
101
|
+
let mut stmt = db.conn().prepare(
|
|
102
|
+
"SELECT id, work_item_id, title, session_title, status, started_at, completed_at, content \
|
|
103
|
+
FROM claude_sessions WHERE id = ?",
|
|
104
|
+
)?;
|
|
105
|
+
let mut rows = stmt.query_map(params![id], row_to_session)?;
|
|
106
|
+
match rows.next() {
|
|
107
|
+
Some(Ok(s)) => Ok(Some(s)),
|
|
108
|
+
Some(Err(e)) => Err(e.into()),
|
|
109
|
+
None => Ok(None),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// Close a session (set status = completed).
|
|
114
|
+
pub fn close_session(db: &Database, id: i64) -> Result<bool> {
|
|
115
|
+
let changed = db.conn().execute(
|
|
116
|
+
"UPDATE claude_sessions SET status = 'completed', completed_at = datetime('now') WHERE id = ?",
|
|
117
|
+
params![id],
|
|
118
|
+
)?;
|
|
119
|
+
Ok(changed > 0)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Close all active sessions for a work item.
|
|
123
|
+
pub fn close_sessions_for_work_item(db: &Database, work_item_id: i64) -> Result<bool> {
|
|
124
|
+
let changed = db.conn().execute(
|
|
125
|
+
"UPDATE claude_sessions SET status = 'completed', completed_at = datetime('now') \
|
|
126
|
+
WHERE work_item_id = ? AND status = 'active'",
|
|
127
|
+
params![work_item_id],
|
|
128
|
+
)?;
|
|
129
|
+
Ok(changed > 0)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Link a session to a work item.
|
|
133
|
+
pub fn link_session_to_work_item(
|
|
134
|
+
db: &Database,
|
|
135
|
+
session_id: i64,
|
|
136
|
+
work_item_id: i64,
|
|
137
|
+
title: &str,
|
|
138
|
+
) -> Result<bool> {
|
|
139
|
+
let changed = db.conn().execute(
|
|
140
|
+
"UPDATE claude_sessions SET work_item_id = ?, title = ? \
|
|
141
|
+
WHERE id = ? AND work_item_id IS NULL",
|
|
142
|
+
params![work_item_id, title, session_id],
|
|
143
|
+
)?;
|
|
144
|
+
Ok(changed > 0)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Get active sessions for a work item.
|
|
148
|
+
pub fn get_active_sessions_for_work_item(
|
|
149
|
+
db: &Database,
|
|
150
|
+
work_item_id: i64,
|
|
151
|
+
) -> Result<Vec<ClaudeSession>> {
|
|
152
|
+
let mut stmt = db.conn().prepare(
|
|
153
|
+
"SELECT id, work_item_id, title, session_title, status, started_at, completed_at, content \
|
|
154
|
+
FROM claude_sessions WHERE work_item_id = ? AND status = 'active'",
|
|
155
|
+
)?;
|
|
156
|
+
let rows = stmt.query_map(params![work_item_id], row_to_session)?;
|
|
157
|
+
rows.map(|r| r.map_err(CoreError::from)).collect()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// Get or create a session for a work item.
|
|
161
|
+
/// Returns `(session, created)` — `created` is true if a new session was made.
|
|
162
|
+
pub fn get_or_create_session(
|
|
163
|
+
db: &Database,
|
|
164
|
+
work_item_id: i64,
|
|
165
|
+
title: &str,
|
|
166
|
+
) -> Result<(ClaudeSession, bool)> {
|
|
167
|
+
let existing = get_active_sessions_for_work_item(db, work_item_id)?;
|
|
168
|
+
if let Some(session) = existing.into_iter().next() {
|
|
169
|
+
return Ok((session, false));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
db.conn().execute(
|
|
173
|
+
"INSERT INTO claude_sessions (work_item_id, title, status, started_at) \
|
|
174
|
+
VALUES (?, ?, 'active', datetime('now'))",
|
|
175
|
+
params![work_item_id, title],
|
|
176
|
+
)?;
|
|
177
|
+
let id = db.conn().last_insert_rowid();
|
|
178
|
+
let session = get_session(db, id)?.unwrap();
|
|
179
|
+
Ok((session, true))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/// Count active sessions.
|
|
183
|
+
pub fn count_active_sessions(db: &Database) -> Result<i64> {
|
|
184
|
+
let count: i64 = db.conn().query_row(
|
|
185
|
+
"SELECT COUNT(*) FROM claude_sessions WHERE status = 'active'",
|
|
186
|
+
[],
|
|
187
|
+
|row| row.get(0),
|
|
188
|
+
)?;
|
|
189
|
+
Ok(count)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// Get all active sessions ordered by most recent activity.
|
|
193
|
+
pub fn get_all_active_sessions(db: &Database) -> Result<Vec<ClaudeSession>> {
|
|
194
|
+
let mut stmt = db.conn().prepare(
|
|
195
|
+
"SELECT id, work_item_id, title, session_title, status, started_at, completed_at, content \
|
|
196
|
+
FROM claude_sessions WHERE status = 'active' \
|
|
197
|
+
ORDER BY COALESCE(completed_at, started_at) DESC",
|
|
198
|
+
)?;
|
|
199
|
+
let rows = stmt.query_map([], row_to_session)?;
|
|
200
|
+
rows.map(|r| r.map_err(CoreError::from)).collect()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// Update session content.
|
|
204
|
+
pub fn set_session_content(db: &Database, id: i64, content: &str) -> Result<()> {
|
|
205
|
+
db.conn().execute(
|
|
206
|
+
"UPDATE claude_sessions SET content = ? WHERE id = ?",
|
|
207
|
+
params![content, id],
|
|
208
|
+
)?;
|
|
209
|
+
Ok(())
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// Clean up stale sessions older than `retention_days`.
|
|
213
|
+
/// Removes orphaned sessions and old completed/error sessions.
|
|
214
|
+
pub fn cleanup_stale_sessions(db: &Database, retention_days: i64) -> Result<usize> {
|
|
215
|
+
let days_str = retention_days.to_string();
|
|
216
|
+
let deleted = db.conn().execute(
|
|
217
|
+
"DELETE FROM claude_sessions \
|
|
218
|
+
WHERE status = 'orphaned' \
|
|
219
|
+
OR (status = 'completed' AND completed_at < datetime('now', '-' || ? || ' days')) \
|
|
220
|
+
OR (status = 'error' AND completed_at < datetime('now', '-' || ? || ' days'))",
|
|
221
|
+
params![days_str, days_str],
|
|
222
|
+
)?;
|
|
223
|
+
Ok(deleted)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Worktree Sessions (DB-backed)
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
/// A worktree session record.
|
|
231
|
+
#[derive(Debug, Clone)]
|
|
232
|
+
pub struct WorktreeSession {
|
|
233
|
+
pub id: i64,
|
|
234
|
+
pub worktree_path: String,
|
|
235
|
+
pub work_item_id: i64,
|
|
236
|
+
pub branch_name: String,
|
|
237
|
+
pub last_activity: String,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
fn row_to_worktree_session(row: &rusqlite::Row) -> rusqlite::Result<WorktreeSession> {
|
|
241
|
+
Ok(WorktreeSession {
|
|
242
|
+
id: row.get("id")?,
|
|
243
|
+
worktree_path: row.get("worktree_path")?,
|
|
244
|
+
work_item_id: row.get("work_item_id")?,
|
|
245
|
+
branch_name: row.get("branch_name")?,
|
|
246
|
+
last_activity: row.get("last_activity")?,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// Create or update a worktree session.
|
|
251
|
+
pub fn upsert_worktree_session(
|
|
252
|
+
db: &Database,
|
|
253
|
+
worktree_path: &str,
|
|
254
|
+
work_item_id: i64,
|
|
255
|
+
branch_name: &str,
|
|
256
|
+
) -> Result<()> {
|
|
257
|
+
db.conn().execute(
|
|
258
|
+
"INSERT INTO worktree_sessions (worktree_path, work_item_id, branch_name) \
|
|
259
|
+
VALUES (?, ?, ?) \
|
|
260
|
+
ON CONFLICT(worktree_path) DO UPDATE SET \
|
|
261
|
+
work_item_id = excluded.work_item_id, \
|
|
262
|
+
branch_name = excluded.branch_name, \
|
|
263
|
+
last_activity = CURRENT_TIMESTAMP",
|
|
264
|
+
params![worktree_path, work_item_id, branch_name],
|
|
265
|
+
)?;
|
|
266
|
+
Ok(())
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/// Get a worktree session by path.
|
|
270
|
+
pub fn get_worktree_session(db: &Database, worktree_path: &str) -> Result<Option<WorktreeSession>> {
|
|
271
|
+
let mut stmt = db.conn().prepare(
|
|
272
|
+
"SELECT * FROM worktree_sessions WHERE worktree_path = ?",
|
|
273
|
+
)?;
|
|
274
|
+
let mut rows = stmt.query_map(params![worktree_path], row_to_worktree_session)?;
|
|
275
|
+
match rows.next() {
|
|
276
|
+
Some(Ok(ws)) => Ok(Some(ws)),
|
|
277
|
+
Some(Err(e)) => Err(e.into()),
|
|
278
|
+
None => Ok(None),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/// Remove a worktree session.
|
|
283
|
+
pub fn remove_worktree_session(db: &Database, worktree_path: &str) -> Result<()> {
|
|
284
|
+
db.conn().execute(
|
|
285
|
+
"DELETE FROM worktree_sessions WHERE worktree_path = ?",
|
|
286
|
+
params![worktree_path],
|
|
287
|
+
)?;
|
|
288
|
+
Ok(())
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Session file (filesystem)
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
/// Map a mode name to its corresponding skill name.
|
|
296
|
+
pub fn mode_to_skill_name(mode: &str) -> String {
|
|
297
|
+
match mode {
|
|
298
|
+
"speed" => "speed-mode".to_string(),
|
|
299
|
+
"stable" => "stable-mode".to_string(),
|
|
300
|
+
"production" => "production-mode".to_string(),
|
|
301
|
+
other => format!("{other}-mode"),
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/// Get the path to the session file: `<cwd>/.claude/session.md`.
|
|
306
|
+
pub fn session_file_path(cwd: &Path) -> PathBuf {
|
|
307
|
+
cwd.join(".claude").join("session.md")
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// Write a session file for mode/context resumption in a worktree.
|
|
311
|
+
///
|
|
312
|
+
/// The session file is `.claude/session.md` (gitignored) and tells Claude Code
|
|
313
|
+
/// which skill to resume when reopening the worktree.
|
|
314
|
+
pub fn write_session_file(
|
|
315
|
+
cwd: &Path,
|
|
316
|
+
work_item_id: i64,
|
|
317
|
+
title: &str,
|
|
318
|
+
item_type: &str,
|
|
319
|
+
status: &str,
|
|
320
|
+
mode: &str,
|
|
321
|
+
epic_id: Option<i64>,
|
|
322
|
+
epic_title: Option<&str>,
|
|
323
|
+
) -> Result<()> {
|
|
324
|
+
let path = session_file_path(cwd);
|
|
325
|
+
|
|
326
|
+
// Ensure .claude directory exists
|
|
327
|
+
if let Some(parent) = path.parent() {
|
|
328
|
+
std::fs::create_dir_all(parent)?;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let skill_name = mode_to_skill_name(mode);
|
|
332
|
+
|
|
333
|
+
let mut content = String::new();
|
|
334
|
+
content.push_str("# Current Work Session\n\n");
|
|
335
|
+
content.push_str("## Work Context\n\n");
|
|
336
|
+
content.push_str(&format!(
|
|
337
|
+
"Working on: [#{}] {} ({})\n",
|
|
338
|
+
work_item_id, title, item_type
|
|
339
|
+
));
|
|
340
|
+
|
|
341
|
+
if let (Some(eid), Some(etitle)) = (epic_id, epic_title) {
|
|
342
|
+
content.push_str(&format!("Epic: [#{}] {}\n", eid, etitle));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
content.push_str(&format!("Mode: {}\n", mode));
|
|
346
|
+
content.push_str(&format!("Status: {}\n", status));
|
|
347
|
+
content.push_str("\n## IMMEDIATE ACTION REQUIRED\n\n");
|
|
348
|
+
content.push_str(&format!(
|
|
349
|
+
"You are resuming work on a {} in {} mode.\n\n",
|
|
350
|
+
item_type, mode
|
|
351
|
+
));
|
|
352
|
+
content.push_str(&format!(
|
|
353
|
+
"**Invoke the {} skill now to continue the workflow.**\n",
|
|
354
|
+
skill_name
|
|
355
|
+
));
|
|
356
|
+
|
|
357
|
+
std::fs::write(&path, content)?;
|
|
358
|
+
Ok(())
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/// Clear the session file.
|
|
362
|
+
pub fn clear_session_file(cwd: &Path) -> Result<()> {
|
|
363
|
+
let path = session_file_path(cwd);
|
|
364
|
+
if path.exists() {
|
|
365
|
+
std::fs::remove_file(&path)?;
|
|
366
|
+
}
|
|
367
|
+
Ok(())
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/// Read the session file content.
|
|
371
|
+
pub fn read_session_file(cwd: &Path) -> Result<Option<String>> {
|
|
372
|
+
let path = session_file_path(cwd);
|
|
373
|
+
if path.exists() {
|
|
374
|
+
Ok(Some(std::fs::read_to_string(&path)?))
|
|
375
|
+
} else {
|
|
376
|
+
Ok(None)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Current work (from git branch)
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
/// Extract a work item ID from a branch name.
|
|
385
|
+
///
|
|
386
|
+
/// Matches patterns like `feature/work-123-title` or `123-title`.
|
|
387
|
+
pub fn extract_work_item_id_from_branch(branch: &str) -> Option<i64> {
|
|
388
|
+
// Try feature/work-{id}-... pattern
|
|
389
|
+
if let Some(rest) = branch.strip_prefix("feature/work-") {
|
|
390
|
+
if let Some(id_str) = rest.split('-').next() {
|
|
391
|
+
if let Ok(id) = id_str.parse::<i64>() {
|
|
392
|
+
return Some(id);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Try bare {id}-... pattern
|
|
398
|
+
if let Some(id_str) = branch.split('-').next() {
|
|
399
|
+
if let Ok(id) = id_str.parse::<i64>() {
|
|
400
|
+
return Some(id);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
None
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/// Get the current work item by inspecting the git branch name.
|
|
408
|
+
/// Returns `None` if not in a git repo, on a non-work branch, or item not found.
|
|
409
|
+
pub fn get_current_work_from_branch(db: &Database, cwd: &Path) -> Result<Option<i64>> {
|
|
410
|
+
let branch = match git::current_branch(cwd) {
|
|
411
|
+
Ok(Some(b)) => b,
|
|
412
|
+
_ => return Ok(None),
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
match extract_work_item_id_from_branch(&branch) {
|
|
416
|
+
Some(id) => {
|
|
417
|
+
// Verify the work item exists and is in_progress
|
|
418
|
+
let exists: bool = db
|
|
419
|
+
.conn()
|
|
420
|
+
.query_row(
|
|
421
|
+
"SELECT COUNT(*) > 0 FROM work_items WHERE id = ? AND status = 'in_progress'",
|
|
422
|
+
params![id],
|
|
423
|
+
|row| row.get(0),
|
|
424
|
+
)
|
|
425
|
+
.unwrap_or(false);
|
|
426
|
+
|
|
427
|
+
if exists {
|
|
428
|
+
Ok(Some(id))
|
|
429
|
+
} else {
|
|
430
|
+
Ok(None)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
None => Ok(None),
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// Tests
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
#[cfg(test)]
|
|
442
|
+
mod tests {
|
|
443
|
+
use super::*;
|
|
444
|
+
use crate::db::Database;
|
|
445
|
+
use tempfile::TempDir;
|
|
446
|
+
|
|
447
|
+
fn setup_db() -> (TempDir, Database) {
|
|
448
|
+
let dir = TempDir::new().unwrap();
|
|
449
|
+
let db_path = dir.path().join("work.db");
|
|
450
|
+
let db = Database::open_path_unchecked(&db_path).unwrap();
|
|
451
|
+
(dir, db)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
#[test]
|
|
455
|
+
fn create_and_get_session() {
|
|
456
|
+
let (_dir, db) = setup_db();
|
|
457
|
+
|
|
458
|
+
let id = create_session(&db, "Test session", Some("My Title")).unwrap();
|
|
459
|
+
assert!(id > 0);
|
|
460
|
+
|
|
461
|
+
let session = get_session(&db, id).unwrap().unwrap();
|
|
462
|
+
assert_eq!(session.title, "Test session");
|
|
463
|
+
assert_eq!(session.session_title.as_deref(), Some("My Title"));
|
|
464
|
+
assert_eq!(session.status, SessionStatus::Active);
|
|
465
|
+
assert!(session.work_item_id.is_none());
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#[test]
|
|
469
|
+
fn close_session_works() {
|
|
470
|
+
let (_dir, db) = setup_db();
|
|
471
|
+
|
|
472
|
+
let id = create_session(&db, "Test", None).unwrap();
|
|
473
|
+
assert!(close_session(&db, id).unwrap());
|
|
474
|
+
|
|
475
|
+
let session = get_session(&db, id).unwrap().unwrap();
|
|
476
|
+
assert_eq!(session.status, SessionStatus::Completed);
|
|
477
|
+
assert!(session.completed_at.is_some());
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
#[test]
|
|
481
|
+
fn link_session_to_work_item_works() {
|
|
482
|
+
let (_dir, db) = setup_db();
|
|
483
|
+
|
|
484
|
+
// Create a work item
|
|
485
|
+
db.conn()
|
|
486
|
+
.execute(
|
|
487
|
+
"INSERT INTO work_items (id, type, title, status) VALUES (1, 'chore', 'My chore', 'in_progress')",
|
|
488
|
+
[],
|
|
489
|
+
)
|
|
490
|
+
.unwrap();
|
|
491
|
+
|
|
492
|
+
let id = create_session(&db, "Unlinked", None).unwrap();
|
|
493
|
+
assert!(link_session_to_work_item(&db, id, 1, "Linked title").unwrap());
|
|
494
|
+
|
|
495
|
+
let session = get_session(&db, id).unwrap().unwrap();
|
|
496
|
+
assert_eq!(session.work_item_id, Some(1));
|
|
497
|
+
assert_eq!(session.title, "Linked title");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#[test]
|
|
501
|
+
fn get_or_create_session_reuses_existing() {
|
|
502
|
+
let (_dir, db) = setup_db();
|
|
503
|
+
|
|
504
|
+
db.conn()
|
|
505
|
+
.execute(
|
|
506
|
+
"INSERT INTO work_items (id, type, title, status) VALUES (1, 'chore', 'Chore', 'in_progress')",
|
|
507
|
+
[],
|
|
508
|
+
)
|
|
509
|
+
.unwrap();
|
|
510
|
+
|
|
511
|
+
let (s1, created1) = get_or_create_session(&db, 1, "Chore").unwrap();
|
|
512
|
+
assert!(created1);
|
|
513
|
+
|
|
514
|
+
let (s2, created2) = get_or_create_session(&db, 1, "Chore").unwrap();
|
|
515
|
+
assert!(!created2);
|
|
516
|
+
assert_eq!(s1.id, s2.id);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
#[test]
|
|
520
|
+
fn count_and_get_all_active() {
|
|
521
|
+
let (_dir, db) = setup_db();
|
|
522
|
+
|
|
523
|
+
create_session(&db, "Session 1", None).unwrap();
|
|
524
|
+
create_session(&db, "Session 2", None).unwrap();
|
|
525
|
+
let id3 = create_session(&db, "Session 3", None).unwrap();
|
|
526
|
+
close_session(&db, id3).unwrap();
|
|
527
|
+
|
|
528
|
+
assert_eq!(count_active_sessions(&db).unwrap(), 2);
|
|
529
|
+
assert_eq!(get_all_active_sessions(&db).unwrap().len(), 2);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
#[test]
|
|
533
|
+
fn session_content_roundtrip() {
|
|
534
|
+
let (_dir, db) = setup_db();
|
|
535
|
+
|
|
536
|
+
let id = create_session(&db, "Test", None).unwrap();
|
|
537
|
+
set_session_content(&db, id, "hello world").unwrap();
|
|
538
|
+
|
|
539
|
+
let session = get_session(&db, id).unwrap().unwrap();
|
|
540
|
+
assert_eq!(session.content.as_deref(), Some("hello world"));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
#[test]
|
|
544
|
+
fn extract_work_item_id_from_branch_works() {
|
|
545
|
+
assert_eq!(
|
|
546
|
+
extract_work_item_id_from_branch("feature/work-1199-port-crud"),
|
|
547
|
+
Some(1199)
|
|
548
|
+
);
|
|
549
|
+
assert_eq!(
|
|
550
|
+
extract_work_item_id_from_branch("42-some-title"),
|
|
551
|
+
Some(42)
|
|
552
|
+
);
|
|
553
|
+
assert_eq!(extract_work_item_id_from_branch("main"), None);
|
|
554
|
+
assert_eq!(extract_work_item_id_from_branch("develop"), None);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
#[test]
|
|
558
|
+
fn mode_to_skill_name_works() {
|
|
559
|
+
assert_eq!(mode_to_skill_name("speed"), "speed-mode");
|
|
560
|
+
assert_eq!(mode_to_skill_name("stable"), "stable-mode");
|
|
561
|
+
assert_eq!(mode_to_skill_name("production"), "production-mode");
|
|
562
|
+
assert_eq!(mode_to_skill_name("custom"), "custom-mode");
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
#[test]
|
|
566
|
+
fn session_file_write_and_read() {
|
|
567
|
+
let dir = TempDir::new().unwrap();
|
|
568
|
+
|
|
569
|
+
write_session_file(
|
|
570
|
+
dir.path(),
|
|
571
|
+
42,
|
|
572
|
+
"My chore",
|
|
573
|
+
"chore",
|
|
574
|
+
"in_progress",
|
|
575
|
+
"speed",
|
|
576
|
+
Some(10),
|
|
577
|
+
Some("Epic title"),
|
|
578
|
+
)
|
|
579
|
+
.unwrap();
|
|
580
|
+
|
|
581
|
+
let content = read_session_file(dir.path()).unwrap().unwrap();
|
|
582
|
+
assert!(content.contains("[#42] My chore"));
|
|
583
|
+
assert!(content.contains("Mode: speed"));
|
|
584
|
+
assert!(content.contains("Epic: [#10] Epic title"));
|
|
585
|
+
assert!(content.contains("speed-mode"));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
#[test]
|
|
589
|
+
fn session_file_clear() {
|
|
590
|
+
let dir = TempDir::new().unwrap();
|
|
591
|
+
|
|
592
|
+
write_session_file(dir.path(), 1, "Test", "chore", "in_progress", "speed", None, None)
|
|
593
|
+
.unwrap();
|
|
594
|
+
assert!(read_session_file(dir.path()).unwrap().is_some());
|
|
595
|
+
|
|
596
|
+
clear_session_file(dir.path()).unwrap();
|
|
597
|
+
assert!(read_session_file(dir.path()).unwrap().is_none());
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
#[test]
|
|
601
|
+
fn worktree_session_upsert_and_get() {
|
|
602
|
+
let (_dir, db) = setup_db();
|
|
603
|
+
|
|
604
|
+
db.conn()
|
|
605
|
+
.execute(
|
|
606
|
+
"INSERT INTO work_items (id, type, title, status) VALUES (1, 'chore', 'Chore', 'in_progress')",
|
|
607
|
+
[],
|
|
608
|
+
)
|
|
609
|
+
.unwrap();
|
|
610
|
+
|
|
611
|
+
upsert_worktree_session(&db, "/tmp/wt-1", 1, "feature/work-1-chore").unwrap();
|
|
612
|
+
|
|
613
|
+
let ws = get_worktree_session(&db, "/tmp/wt-1").unwrap().unwrap();
|
|
614
|
+
assert_eq!(ws.work_item_id, 1);
|
|
615
|
+
assert_eq!(ws.branch_name, "feature/work-1-chore");
|
|
616
|
+
|
|
617
|
+
// Upsert again should update
|
|
618
|
+
upsert_worktree_session(&db, "/tmp/wt-1", 1, "feature/work-1-updated").unwrap();
|
|
619
|
+
let ws = get_worktree_session(&db, "/tmp/wt-1").unwrap().unwrap();
|
|
620
|
+
assert_eq!(ws.branch_name, "feature/work-1-updated");
|
|
621
|
+
|
|
622
|
+
remove_worktree_session(&db, "/tmp/wt-1").unwrap();
|
|
623
|
+
assert!(get_worktree_session(&db, "/tmp/wt-1").unwrap().is_none());
|
|
624
|
+
}
|
|
625
|
+
}
|