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.
Files changed (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -0,0 +1,628 @@
1
+ //! Worktree lifecycle management.
2
+ //!
3
+ //! Manages the full lifecycle of git worktrees used for isolated work:
4
+ //! creating worktrees (with DB tracking), cleaning them up (multi-stage
5
+ //! resilient removal), and querying their state.
6
+ //!
7
+ //! **Database is the single source of truth.** Every worktree operation
8
+ //! persists state to the `worktrees` table before touching the filesystem.
9
+
10
+ use std::fs;
11
+ use std::path::{Path, PathBuf};
12
+
13
+ use rusqlite::params;
14
+
15
+ use crate::config;
16
+ use crate::db::Database;
17
+ use crate::error::{CoreError, GitError, Result, WorkError};
18
+ use crate::git;
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Types
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /// A worktree record as stored in the database.
25
+ #[derive(Debug, Clone)]
26
+ pub struct Worktree {
27
+ pub id: i64,
28
+ pub work_item_id: i64,
29
+ pub worktree_path: String,
30
+ pub branch_name: String,
31
+ pub status: WorktreeStatus,
32
+ pub created_at: String,
33
+ pub updated_at: String,
34
+ }
35
+
36
+ /// Valid worktree lifecycle states.
37
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
38
+ pub enum WorktreeStatus {
39
+ Active,
40
+ Merging,
41
+ CleanupPending,
42
+ Cleaned,
43
+ }
44
+
45
+ impl WorktreeStatus {
46
+ pub fn as_str(&self) -> &'static str {
47
+ match self {
48
+ Self::Active => "active",
49
+ Self::Merging => "merging",
50
+ Self::CleanupPending => "cleanup_pending",
51
+ Self::Cleaned => "cleaned",
52
+ }
53
+ }
54
+ }
55
+
56
+ impl std::fmt::Display for WorktreeStatus {
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 WorktreeStatus {
63
+ type Err = CoreError;
64
+ fn from_str(s: &str) -> Result<Self> {
65
+ match s {
66
+ "active" => Ok(Self::Active),
67
+ "merging" => Ok(Self::Merging),
68
+ "cleanup_pending" => Ok(Self::CleanupPending),
69
+ "cleaned" => Ok(Self::Cleaned),
70
+ _ => Err(WorkError::InvalidState(format!("invalid worktree status: {s}")).into()),
71
+ }
72
+ }
73
+ }
74
+
75
+ fn row_to_worktree(row: &rusqlite::Row) -> rusqlite::Result<Worktree> {
76
+ let status_str: String = row.get("status")?;
77
+ Ok(Worktree {
78
+ id: row.get("id")?,
79
+ work_item_id: row.get("work_item_id")?,
80
+ worktree_path: row.get("worktree_path")?,
81
+ branch_name: row.get("branch_name")?,
82
+ status: status_str.parse().unwrap_or(WorktreeStatus::Active),
83
+ created_at: row.get("created_at")?,
84
+ updated_at: row.get("updated_at")?,
85
+ })
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Queries
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /// Get the most recent worktree for a work item (any status).
93
+ pub fn get_for_work_item(db: &Database, work_item_id: i64) -> Result<Option<Worktree>> {
94
+ let mut stmt = db.conn().prepare(
95
+ "SELECT * FROM worktrees WHERE work_item_id = ? ORDER BY created_at DESC LIMIT 1",
96
+ )?;
97
+ let mut rows = stmt.query_map(params![work_item_id], row_to_worktree)?;
98
+ match rows.next() {
99
+ Some(Ok(wt)) => Ok(Some(wt)),
100
+ Some(Err(e)) => Err(e.into()),
101
+ None => Ok(None),
102
+ }
103
+ }
104
+
105
+ /// Get all worktrees with `status = 'active'`.
106
+ pub fn get_all_active(db: &Database) -> Result<Vec<Worktree>> {
107
+ let mut stmt = db.conn().prepare(
108
+ "SELECT * FROM worktrees WHERE status = 'active' ORDER BY created_at DESC",
109
+ )?;
110
+ let rows = stmt.query_map([], row_to_worktree)?;
111
+ rows.map(|r| r.map_err(CoreError::from)).collect()
112
+ }
113
+
114
+ /// Update a worktree's status.
115
+ pub fn mark_status(db: &Database, worktree_id: i64, status: WorktreeStatus) -> Result<()> {
116
+ let changed = db.conn().execute(
117
+ "UPDATE worktrees SET status = ?, updated_at = datetime('now') WHERE id = ?",
118
+ params![status.as_str(), worktree_id],
119
+ )?;
120
+ if changed == 0 {
121
+ return Err(WorkError::Other(format!("worktree not found: {worktree_id}")).into());
122
+ }
123
+ Ok(())
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Create worktree
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /// Options for creating a worktree.
131
+ pub struct CreateWorktreeOpts<'a> {
132
+ /// The work item id and title (used to generate branch name + path).
133
+ pub work_item_id: i64,
134
+ pub title: &'a str,
135
+ /// Absolute path to the **main** repository root.
136
+ pub repo_root: &'a Path,
137
+ }
138
+
139
+ /// Create a new worktree for a work item.
140
+ ///
141
+ /// 1. Inserts a DB record with status='active'.
142
+ /// 2. Runs `git worktree add`.
143
+ /// 3. Creates `.jettypod` symlink for shared DB access.
144
+ /// 4. Symlinks `.env*` files.
145
+ ///
146
+ /// On failure, rolls back the DB record and cleans up any partial git state.
147
+ pub fn create(db: &Database, opts: &CreateWorktreeOpts) -> Result<Worktree> {
148
+ let title_slug = git::slugify(opts.title);
149
+ let branch_name = format!("feature/work-{}-{}", opts.work_item_id, title_slug);
150
+ let worktree_base = opts.repo_root.join(".jettypod-work");
151
+ let worktree_path = worktree_base.join(format!("{}-{}", opts.work_item_id, title_slug));
152
+ let worktree_path_str = worktree_path.to_string_lossy().to_string();
153
+
154
+ // Resolve default branch
155
+ let default_branch = config::resolve_default_branch(opts.repo_root);
156
+
157
+ // Auto-commit untracked BDD files so the worktree branch gets them
158
+ auto_commit_bdd_files(opts.repo_root, opts.work_item_id);
159
+
160
+ let mut worktree_id: Option<i64> = None;
161
+ let mut git_worktree_created = false;
162
+
163
+ // --- transactional creation ---
164
+ let result = (|| -> Result<Worktree> {
165
+ // Step 1: Insert DB record
166
+ db.conn().execute(
167
+ "INSERT INTO worktrees (work_item_id, branch_name, worktree_path, status) \
168
+ VALUES (?, ?, ?, 'active')",
169
+ params![opts.work_item_id, branch_name, worktree_path_str],
170
+ )?;
171
+ worktree_id = Some(db.conn().last_insert_rowid());
172
+
173
+ // Step 2: Create git worktree
174
+ git::worktree_add(opts.repo_root, &branch_name, &worktree_path, &default_branch)?;
175
+ git_worktree_created = true;
176
+
177
+ // Step 3: Verify directory exists
178
+ if !worktree_path.exists() {
179
+ return Err(GitError::Other(format!(
180
+ "worktree directory was not created: {}",
181
+ worktree_path.display()
182
+ )).into());
183
+ }
184
+
185
+ // Step 4: Create .jettypod symlink
186
+ setup_jettypod_symlink(opts.repo_root, &worktree_path)?;
187
+
188
+ // Step 5: Symlink .env files
189
+ symlink_env_files(opts.repo_root, &worktree_path);
190
+
191
+ // Step 6: Return the created record
192
+ let wt = db.conn().query_row(
193
+ "SELECT * FROM worktrees WHERE id = ?",
194
+ params![worktree_id.unwrap()],
195
+ row_to_worktree,
196
+ )?;
197
+ Ok(wt)
198
+ })();
199
+
200
+ // --- rollback on error ---
201
+ if let Err(ref _e) = result {
202
+ // Clean up DB
203
+ if let Some(id) = worktree_id {
204
+ if let Err(e) = db.conn().execute("DELETE FROM worktrees WHERE id = ?", params![id]) {
205
+ eprintln!("Warning: rollback cleanup failed (delete worktree record): {e}");
206
+ }
207
+ }
208
+ // Clean up git worktree
209
+ if git_worktree_created {
210
+ if let Err(e) = git::worktree_remove(opts.repo_root, &worktree_path) {
211
+ eprintln!("Warning: rollback cleanup failed (git worktree remove): {e}");
212
+ }
213
+ }
214
+ // Clean up leftover directory
215
+ if worktree_path.exists() {
216
+ if let Err(e) = fs::remove_dir_all(&worktree_path) {
217
+ eprintln!("Warning: rollback cleanup failed (remove directory): {e}");
218
+ }
219
+ }
220
+ }
221
+
222
+ result
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Cleanup worktree
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /// Options for cleaning up a worktree.
230
+ pub struct CleanupWorktreeOpts<'a> {
231
+ /// Absolute path to the **main** repository root.
232
+ pub repo_root: &'a Path,
233
+ /// Whether to delete the git branch after cleanup.
234
+ pub delete_branch: bool,
235
+ }
236
+
237
+ /// Clean up a worktree: remove from filesystem, update DB status.
238
+ ///
239
+ /// Uses a resilient multi-stage removal strategy:
240
+ /// 1. `git worktree remove`
241
+ /// 2. `git worktree remove --force`
242
+ /// 3. `rm -rf` + `git worktree prune`
243
+ pub fn cleanup(db: &Database, worktree_id: i64, opts: &CleanupWorktreeOpts) -> Result<()> {
244
+ // Fetch the worktree record
245
+ let wt = db.conn().query_row(
246
+ "SELECT * FROM worktrees WHERE id = ?",
247
+ params![worktree_id],
248
+ row_to_worktree,
249
+ )?;
250
+
251
+ // Mark as cleanup_pending
252
+ mark_status(db, worktree_id, WorktreeStatus::CleanupPending)?;
253
+
254
+ let cleanup_result = (|| -> Result<()> {
255
+ // Remove the worktree directory (resilient multi-stage)
256
+ let wt_path = PathBuf::from(&wt.worktree_path);
257
+
258
+ // Safety: only remove paths inside .jettypod-work
259
+ let worktree_base = opts.repo_root.join(".jettypod-work");
260
+ if !wt_path.starts_with(&worktree_base) {
261
+ return Err(GitError::Other(format!(
262
+ "SAFETY: can only remove directories within .jettypod-work/. Got: {}",
263
+ wt_path.display()
264
+ )).into());
265
+ }
266
+
267
+ if wt_path.exists() {
268
+ git::worktree_remove(opts.repo_root, &wt_path)?;
269
+ }
270
+
271
+ // Optionally delete the branch
272
+ if opts.delete_branch {
273
+ // Non-fatal if branch deletion fails
274
+ if let Err(e) = git::delete_branch(opts.repo_root, &wt.branch_name) {
275
+ eprintln!("Warning: Failed to delete branch {}: {e}", wt.branch_name);
276
+ }
277
+ }
278
+
279
+ Ok(())
280
+ })();
281
+
282
+ // Always mark as cleaned (terminal state) regardless of success
283
+ if let Err(e) = mark_status(db, worktree_id, WorktreeStatus::Cleaned) {
284
+ eprintln!("Warning: Failed to mark worktree as cleaned: {e}");
285
+ }
286
+
287
+ cleanup_result
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Helpers
292
+ // ---------------------------------------------------------------------------
293
+
294
+ /// Auto-commit untracked BDD files (.feature, steps/) so the worktree gets them.
295
+ fn auto_commit_bdd_files(repo_root: &Path, work_item_id: i64) {
296
+ let untracked = match git::untracked_files(repo_root) {
297
+ Ok(f) => f,
298
+ Err(_) => return,
299
+ };
300
+
301
+ let bdd_files: Vec<&str> = untracked
302
+ .iter()
303
+ .filter(|f| {
304
+ f.ends_with(".feature")
305
+ || f.contains("step_definitions/")
306
+ || f.contains("steps/")
307
+ })
308
+ .map(|s| s.as_str())
309
+ .collect();
310
+
311
+ if bdd_files.is_empty() {
312
+ return;
313
+ }
314
+
315
+ for f in &bdd_files {
316
+ if let Err(e) = git::run(repo_root, &["add", f]) {
317
+ eprintln!("Warning: Failed to stage backup file {}: {e}", f);
318
+ }
319
+ }
320
+ let msg = format!("Add BDD scenarios for work item #{work_item_id}");
321
+ if let Err(e) = git::run(repo_root, &["commit", "-m", &msg]) {
322
+ eprintln!("Warning: Failed to commit backup: {e}");
323
+ }
324
+ }
325
+
326
+ /// Create a `.jettypod` symlink inside the worktree pointing to the main repo's `.jettypod`.
327
+ fn setup_jettypod_symlink(repo_root: &Path, worktree_path: &Path) -> Result<()> {
328
+ let link_path = worktree_path.join(".jettypod");
329
+ let target = repo_root.join(".jettypod");
330
+
331
+ if link_path.exists() || link_path.symlink_metadata().is_ok() {
332
+ // Check if it's already a correct symlink
333
+ if link_path.symlink_metadata().map(|m| m.file_type().is_symlink()).unwrap_or(false) {
334
+ let current_target = fs::read_link(&link_path)?;
335
+ let resolved = worktree_path.join(&current_target);
336
+ let resolved_canon = resolved.canonicalize().unwrap_or(resolved);
337
+ let target_canon = target.canonicalize().unwrap_or_else(|_| target.clone());
338
+ if resolved_canon == target_canon {
339
+ return Ok(()); // already correct
340
+ }
341
+ fs::remove_file(&link_path)?;
342
+ } else {
343
+ // It's a directory (git may create one) — remove it
344
+ fs::remove_dir_all(&link_path)?;
345
+ }
346
+ }
347
+
348
+ #[cfg(unix)]
349
+ std::os::unix::fs::symlink(&target, &link_path)?;
350
+
351
+ #[cfg(not(unix))]
352
+ return Err(GitError::Other("symlinks not supported on this platform".into()).into());
353
+
354
+ Ok(())
355
+ }
356
+
357
+ /// Symlink `.env*` files from the main repo into the worktree.
358
+ fn symlink_env_files(repo_root: &Path, worktree_path: &Path) {
359
+ let entries = match fs::read_dir(repo_root) {
360
+ Ok(e) => e,
361
+ Err(_) => return,
362
+ };
363
+
364
+ for entry in entries.flatten() {
365
+ let name = entry.file_name();
366
+ let name_str = name.to_string_lossy();
367
+ if !name_str.starts_with(".env") {
368
+ continue;
369
+ }
370
+ let source = entry.path();
371
+ if !source.is_file() {
372
+ continue;
373
+ }
374
+ let dest = worktree_path.join(&name);
375
+ if dest.exists() {
376
+ continue;
377
+ }
378
+
379
+ #[cfg(unix)]
380
+ {
381
+ if let Err(e) = std::os::unix::fs::symlink(&source, &dest) {
382
+ eprintln!("Warning: Failed to create CLAUDE.md symlink: {e}");
383
+ }
384
+ }
385
+ }
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Merge lock
390
+ // ---------------------------------------------------------------------------
391
+
392
+ /// Acquire an exclusive merge lock. Returns the lock id.
393
+ /// Blocks for up to `timeout_secs`, polling every 2 seconds.
394
+ ///
395
+ /// Schema: merge_locks(id, locked_by, locked_at, operation, work_item_id, heartbeat_at)
396
+ pub fn acquire_merge_lock(
397
+ db: &Database,
398
+ work_item_id: i64,
399
+ locked_by: &str,
400
+ timeout_secs: u64,
401
+ ) -> Result<i64> {
402
+ let start = std::time::Instant::now();
403
+ let poll_interval = std::time::Duration::from_secs(2);
404
+
405
+ loop {
406
+ // Clean up stale locks (no heartbeat in 2+ minutes)
407
+ db.conn().execute(
408
+ "DELETE FROM merge_locks WHERE heartbeat_at < datetime('now', '-2 minutes')",
409
+ [],
410
+ )?;
411
+
412
+ // Try to acquire
413
+ let existing: Option<i64> = db
414
+ .conn()
415
+ .query_row(
416
+ "SELECT id FROM merge_locks LIMIT 1",
417
+ [],
418
+ |row| row.get(0),
419
+ )
420
+ .ok();
421
+
422
+ if existing.is_none() {
423
+ db.conn().execute(
424
+ "INSERT INTO merge_locks (work_item_id, locked_by, operation) \
425
+ VALUES (?, ?, 'merging')",
426
+ params![work_item_id, locked_by],
427
+ )?;
428
+ let lock_id = db.conn().last_insert_rowid();
429
+ return Ok(lock_id);
430
+ }
431
+
432
+ if start.elapsed().as_secs() >= timeout_secs {
433
+ return Err(WorkError::Other(
434
+ "timed out waiting for merge lock".into(),
435
+ ).into());
436
+ }
437
+
438
+ std::thread::sleep(poll_interval);
439
+ }
440
+ }
441
+
442
+ /// Release a merge lock.
443
+ pub fn release_merge_lock(db: &Database, lock_id: i64) -> Result<()> {
444
+ db.conn()
445
+ .execute("DELETE FROM merge_locks WHERE id = ?", params![lock_id])?;
446
+ Ok(())
447
+ }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // Tests
451
+ // ---------------------------------------------------------------------------
452
+
453
+ #[cfg(test)]
454
+ mod tests {
455
+ use super::*;
456
+ use crate::db::Database;
457
+ use tempfile::TempDir;
458
+
459
+ fn setup() -> (TempDir, Database) {
460
+ let dir = TempDir::new().unwrap();
461
+ // Init git repo
462
+ git::run(dir.path(), &["init"]).unwrap();
463
+ git::run(dir.path(), &["config", "user.email", "test@test.com"]).unwrap();
464
+ git::run(dir.path(), &["config", "user.name", "Test"]).unwrap();
465
+ std::fs::write(dir.path().join("README.md"), "init").unwrap();
466
+ git::run(dir.path(), &["add", "."]).unwrap();
467
+ git::run(dir.path(), &["commit", "-m", "init"]).unwrap();
468
+
469
+ // Create .jettypod dir + DB
470
+ let jettypod = dir.path().join(".jettypod");
471
+ std::fs::create_dir_all(&jettypod).unwrap();
472
+ let db_path = jettypod.join("work.db");
473
+ let db = Database::open_path_unchecked(&db_path).unwrap();
474
+
475
+ // Insert a work item to reference
476
+ db.conn()
477
+ .execute(
478
+ "INSERT INTO work_items (id, type, title, status) VALUES (1, 'chore', 'Test chore', 'todo')",
479
+ [],
480
+ )
481
+ .unwrap();
482
+
483
+ // Commit .jettypod so git worktree add doesn't complain about untracked
484
+ git::run(dir.path(), &["add", "."]).unwrap();
485
+ git::run(dir.path(), &["commit", "-m", "add jettypod"]).unwrap();
486
+
487
+ (dir, db)
488
+ }
489
+
490
+ #[test]
491
+ fn create_and_get_worktree() {
492
+ let (dir, db) = setup();
493
+
494
+ let wt = create(
495
+ &db,
496
+ &CreateWorktreeOpts {
497
+ work_item_id: 1,
498
+ title: "Test chore",
499
+ repo_root: dir.path(),
500
+ },
501
+ )
502
+ .unwrap();
503
+
504
+ assert_eq!(wt.work_item_id, 1);
505
+ assert_eq!(wt.status, WorktreeStatus::Active);
506
+ assert!(wt.branch_name.contains("test-chore"));
507
+ assert!(PathBuf::from(&wt.worktree_path).exists());
508
+
509
+ // .jettypod symlink should exist
510
+ let jettypod_link = PathBuf::from(&wt.worktree_path).join(".jettypod");
511
+ assert!(jettypod_link.exists());
512
+
513
+ // Query it back
514
+ let found = get_for_work_item(&db, 1).unwrap().unwrap();
515
+ assert_eq!(found.id, wt.id);
516
+ }
517
+
518
+ #[test]
519
+ fn cleanup_removes_worktree() {
520
+ let (dir, db) = setup();
521
+
522
+ let wt = create(
523
+ &db,
524
+ &CreateWorktreeOpts {
525
+ work_item_id: 1,
526
+ title: "Test chore",
527
+ repo_root: dir.path(),
528
+ },
529
+ )
530
+ .unwrap();
531
+
532
+ let wt_path = PathBuf::from(&wt.worktree_path);
533
+ assert!(wt_path.exists());
534
+
535
+ cleanup(
536
+ &db,
537
+ wt.id,
538
+ &CleanupWorktreeOpts {
539
+ repo_root: dir.path(),
540
+ delete_branch: true,
541
+ },
542
+ )
543
+ .unwrap();
544
+
545
+ assert!(!wt_path.exists());
546
+
547
+ let found = get_for_work_item(&db, 1).unwrap().unwrap();
548
+ assert_eq!(found.status, WorktreeStatus::Cleaned);
549
+ }
550
+
551
+ #[test]
552
+ fn get_all_active_works() {
553
+ let (dir, db) = setup();
554
+
555
+ // Insert a second work item
556
+ db.conn()
557
+ .execute(
558
+ "INSERT INTO work_items (id, type, title, status) VALUES (2, 'chore', 'Second', 'todo')",
559
+ [],
560
+ )
561
+ .unwrap();
562
+
563
+ let _wt1 = create(
564
+ &db,
565
+ &CreateWorktreeOpts {
566
+ work_item_id: 1,
567
+ title: "First",
568
+ repo_root: dir.path(),
569
+ },
570
+ )
571
+ .unwrap();
572
+
573
+ let _wt2 = create(
574
+ &db,
575
+ &CreateWorktreeOpts {
576
+ work_item_id: 2,
577
+ title: "Second",
578
+ repo_root: dir.path(),
579
+ },
580
+ )
581
+ .unwrap();
582
+
583
+ let active = get_all_active(&db).unwrap();
584
+ assert_eq!(active.len(), 2);
585
+ }
586
+
587
+ #[test]
588
+ fn mark_status_works() {
589
+ let (dir, db) = setup();
590
+
591
+ let wt = create(
592
+ &db,
593
+ &CreateWorktreeOpts {
594
+ work_item_id: 1,
595
+ title: "Test",
596
+ repo_root: dir.path(),
597
+ },
598
+ )
599
+ .unwrap();
600
+
601
+ mark_status(&db, wt.id, WorktreeStatus::Merging).unwrap();
602
+ let found = get_for_work_item(&db, 1).unwrap().unwrap();
603
+ assert_eq!(found.status, WorktreeStatus::Merging);
604
+ }
605
+
606
+ #[test]
607
+ fn merge_lock_acquire_release() {
608
+ let (_dir, db) = setup();
609
+
610
+ let lock_id = acquire_merge_lock(&db, 1, "session-1", 5).unwrap();
611
+ assert!(lock_id > 0);
612
+
613
+ release_merge_lock(&db, lock_id).unwrap();
614
+
615
+ // Should be able to acquire again
616
+ let lock_id2 = acquire_merge_lock(&db, 1, "session-2", 5).unwrap();
617
+ release_merge_lock(&db, lock_id2).unwrap();
618
+ }
619
+
620
+ #[test]
621
+ fn worktree_status_roundtrip() {
622
+ assert_eq!("active".parse::<WorktreeStatus>().unwrap(), WorktreeStatus::Active);
623
+ assert_eq!("merging".parse::<WorktreeStatus>().unwrap(), WorktreeStatus::Merging);
624
+ assert_eq!("cleanup_pending".parse::<WorktreeStatus>().unwrap(), WorktreeStatus::CleanupPending);
625
+ assert_eq!("cleaned".parse::<WorktreeStatus>().unwrap(), WorktreeStatus::Cleaned);
626
+ assert!("invalid".parse::<WorktreeStatus>().is_err());
627
+ }
628
+ }