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,458 @@
1
+ //! Git operations — shells out to the git CLI.
2
+ //!
3
+ //! Decision D1: We use `std::process::Command` to call `git` rather than
4
+ //! libgit2/git2-rs, because git2 has incomplete worktree support and
5
+ //! the CLI gives us exact parity with the Node.js implementation.
6
+
7
+ use std::path::{Path, PathBuf};
8
+ use std::process::Command;
9
+
10
+ use crate::error::{GitError, Result};
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Low-level git command helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /// Run a git command in `cwd`, returning trimmed stdout on success.
17
+ /// Pipes both stdout and stderr to avoid stealing the terminal.
18
+ pub fn run(cwd: &Path, args: &[&str]) -> Result<String> {
19
+ let output = Command::new("git")
20
+ .args(args)
21
+ .current_dir(cwd)
22
+ .stdout(std::process::Stdio::piped())
23
+ .stderr(std::process::Stdio::piped())
24
+ .output()
25
+ .map_err(|e| GitError::NotAvailable(format!("{e}")))?;
26
+
27
+ if output.status.success() {
28
+ Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
29
+ } else {
30
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
31
+ Err(GitError::CommandFailed {
32
+ args: args.join(" "),
33
+ stderr,
34
+ }
35
+ .into())
36
+ }
37
+ }
38
+
39
+ /// Run a git command that may legitimately fail (e.g. branch already exists).
40
+ /// Returns Ok(Some(stdout)) on success, Ok(None) on non-zero exit.
41
+ pub fn try_run(cwd: &Path, args: &[&str]) -> Result<Option<String>> {
42
+ let output = Command::new("git")
43
+ .args(args)
44
+ .current_dir(cwd)
45
+ .stdout(std::process::Stdio::piped())
46
+ .stderr(std::process::Stdio::piped())
47
+ .output()
48
+ .map_err(|e| GitError::NotAvailable(format!("{e}")))?;
49
+
50
+ if output.status.success() {
51
+ Ok(Some(String::from_utf8_lossy(&output.stdout).trim().to_string()))
52
+ } else {
53
+ Ok(None)
54
+ }
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Queries
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /// Check if git is installed.
62
+ pub fn is_installed() -> bool {
63
+ Command::new("git")
64
+ .arg("--version")
65
+ .stdout(std::process::Stdio::piped())
66
+ .stderr(std::process::Stdio::piped())
67
+ .output()
68
+ .map(|o| o.status.success())
69
+ .unwrap_or(false)
70
+ }
71
+
72
+ /// Check if `cwd` is inside a git repository.
73
+ pub fn is_repo(cwd: &Path) -> bool {
74
+ try_run(cwd, &["rev-parse", "--git-dir"])
75
+ .ok()
76
+ .flatten()
77
+ .is_some()
78
+ }
79
+
80
+ /// Get the current branch name, or `None` if in detached HEAD state.
81
+ pub fn current_branch(cwd: &Path) -> Result<Option<String>> {
82
+ let branch = run(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?;
83
+ if branch == "HEAD" {
84
+ Ok(None) // detached HEAD
85
+ } else {
86
+ Ok(Some(branch))
87
+ }
88
+ }
89
+
90
+ /// Get `--show-toplevel` for `cwd`.
91
+ pub fn toplevel(cwd: &Path) -> Result<PathBuf> {
92
+ let s = run(cwd, &["rev-parse", "--show-toplevel"])?;
93
+ Ok(PathBuf::from(s))
94
+ }
95
+
96
+ /// Get `git status --porcelain` output. Empty string means clean.
97
+ pub fn status_porcelain(cwd: &Path) -> Result<String> {
98
+ run(cwd, &["status", "--porcelain"])
99
+ }
100
+
101
+ /// List changed files between two refs.
102
+ pub fn diff_names(cwd: &Path, from: &str, to: &str) -> Result<Vec<String>> {
103
+ let output = run(cwd, &["diff", "--name-only", from, to])?;
104
+ Ok(output.lines().map(|s| s.to_string()).collect())
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Git root detection (safe, cached-friendly)
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /// Find the **main** repository root (never a worktree).
112
+ ///
113
+ /// Uses `--git-common-dir` to find the shared `.git` directory, resolves to
114
+ /// an absolute path, then runs safety checks:
115
+ /// 1. `.jettypod` directory must exist in the root
116
+ /// 2. `--show-toplevel` must match the computed root
117
+ /// 3. Path must not contain `.jettypod-work`
118
+ pub fn find_main_repo_root(cwd: &Path) -> Result<PathBuf> {
119
+ let git_common = run(cwd, &["rev-parse", "--git-common-dir"])?;
120
+
121
+ let abs_git_dir = if Path::new(&git_common).is_absolute() {
122
+ PathBuf::from(&git_common)
123
+ } else {
124
+ cwd.join(&git_common)
125
+ };
126
+
127
+ // The main repo root is the parent of the .git directory
128
+ let git_root = abs_git_dir
129
+ .parent()
130
+ .ok_or_else(|| GitError::Other("cannot determine parent of .git dir".into()))?;
131
+
132
+ // Canonicalize to handle /var ↔ /private/var on macOS
133
+ let git_root = git_root
134
+ .canonicalize()
135
+ .map_err(|e| GitError::Other(format!("canonicalize git root: {e}")))?;
136
+
137
+ // Safety check 1: .jettypod must exist
138
+ let jettypod_dir = git_root.join(".jettypod");
139
+ if !jettypod_dir.exists() {
140
+ return Err(GitError::NotARepo(format!(
141
+ "SAFETY: .jettypod not found in {} — may be a worktree",
142
+ git_root.display()
143
+ ))
144
+ .into());
145
+ }
146
+
147
+ // Safety check 2: --show-toplevel must match
148
+ let top = toplevel(&git_root)?;
149
+ let top_canon = top
150
+ .canonicalize()
151
+ .map_err(|e| GitError::Other(format!("canonicalize toplevel: {e}")))?;
152
+ if top_canon != git_root {
153
+ return Err(GitError::NotARepo(format!(
154
+ "SAFETY: computed root {} != toplevel {}",
155
+ git_root.display(),
156
+ top_canon.display()
157
+ ))
158
+ .into());
159
+ }
160
+
161
+ // Safety check 3: must not be inside a worktree path
162
+ let root_str = git_root.to_string_lossy();
163
+ if root_str.contains(".jettypod-work") {
164
+ return Err(GitError::NotARepo(
165
+ "SAFETY: computed root is inside .jettypod-work".into(),
166
+ )
167
+ .into());
168
+ }
169
+
170
+ Ok(git_root)
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Branch helpers
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /// Validate a branch name against git naming rules.
178
+ pub fn validate_branch_name(name: &str) -> Result<()> {
179
+ if name.is_empty() {
180
+ return Err(GitError::InvalidBranch("branch name must be non-empty".into()).into());
181
+ }
182
+ if name.contains(' ') {
183
+ return Err(GitError::InvalidBranch("branch name cannot contain spaces".into()).into());
184
+ }
185
+ if name.starts_with('-') || name.starts_with('.') {
186
+ return Err(GitError::InvalidBranch("branch name cannot start with - or .".into()).into());
187
+ }
188
+ if name.chars().any(|c| "~^:\\?*[".contains(c)) {
189
+ return Err(
190
+ GitError::InvalidBranch("branch name contains invalid characters".into()).into(),
191
+ );
192
+ }
193
+ Ok(())
194
+ }
195
+
196
+ /// Convert text to a git-safe slug: lowercase, hyphens, alphanumeric only.
197
+ pub fn slugify(text: &str) -> String {
198
+ text.to_lowercase()
199
+ .split_whitespace()
200
+ .collect::<Vec<_>>()
201
+ .join("-")
202
+ .chars()
203
+ .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
204
+ .collect()
205
+ }
206
+
207
+ /// Create a branch name from work item id + title.
208
+ /// Returns `feature/work-{id}-{slug}`.
209
+ pub fn branch_name_for(id: i64, title: &str) -> String {
210
+ format!("feature/work-{}-{}", id, slugify(title))
211
+ }
212
+
213
+ /// Create or checkout a branch.
214
+ pub fn create_or_checkout_branch(cwd: &Path, name: &str) -> Result<()> {
215
+ validate_branch_name(name)?;
216
+
217
+ // Try creating new branch
218
+ if try_run(cwd, &["checkout", "-b", name])?.is_some() {
219
+ return Ok(());
220
+ }
221
+
222
+ // Branch exists — checkout
223
+ run(cwd, &["checkout", name])?;
224
+ Ok(())
225
+ }
226
+
227
+ /// Delete a branch (force).
228
+ pub fn delete_branch(cwd: &Path, name: &str) -> Result<()> {
229
+ run(cwd, &["branch", "-D", name])?;
230
+ Ok(())
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Worktree git commands (low-level)
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /// Create a git worktree at `path` on a new branch from `base_branch`.
238
+ pub fn worktree_add(
239
+ repo_root: &Path,
240
+ branch: &str,
241
+ path: &Path,
242
+ base_branch: &str,
243
+ ) -> Result<()> {
244
+ let path_str = path.to_string_lossy();
245
+ run(
246
+ repo_root,
247
+ &["worktree", "add", "-b", branch, &path_str, base_branch],
248
+ )?;
249
+ Ok(())
250
+ }
251
+
252
+ /// Remove a git worktree. Returns Ok(()) even if the directory is already gone.
253
+ pub fn worktree_remove(repo_root: &Path, path: &Path) -> Result<()> {
254
+ let path_str = path.to_string_lossy();
255
+
256
+ // Stage 1: standard remove
257
+ if try_run(repo_root, &["worktree", "remove", &path_str])?.is_some() {
258
+ return Ok(());
259
+ }
260
+
261
+ // Stage 2: forced remove
262
+ if try_run(repo_root, &["worktree", "remove", "--force", &path_str])?.is_some() {
263
+ return Ok(());
264
+ }
265
+
266
+ // Stage 3: directory removal + prune
267
+ if path.exists() {
268
+ std::fs::remove_dir_all(path)?;
269
+ }
270
+ let _ = try_run(repo_root, &["worktree", "prune"]);
271
+
272
+ Ok(())
273
+ }
274
+
275
+ /// Prune stale worktree metadata.
276
+ pub fn worktree_prune(repo_root: &Path) -> Result<()> {
277
+ run(repo_root, &["worktree", "prune"])?;
278
+ Ok(())
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Merge helpers
283
+ // ---------------------------------------------------------------------------
284
+
285
+ /// Push the current branch to origin with upstream tracking.
286
+ pub fn push_with_upstream(cwd: &Path, branch: &str) -> Result<()> {
287
+ run(cwd, &["push", "-u", "origin", branch])?;
288
+ Ok(())
289
+ }
290
+
291
+ /// Fetch from origin.
292
+ pub fn fetch_origin(cwd: &Path) -> Result<()> {
293
+ run(cwd, &["fetch", "origin"])?;
294
+ Ok(())
295
+ }
296
+
297
+ /// Pull the given branch from origin.
298
+ pub fn pull_origin(cwd: &Path, branch: &str) -> Result<()> {
299
+ run(cwd, &["pull", "origin", branch])?;
300
+ Ok(())
301
+ }
302
+
303
+ /// Merge `branch` with `--no-ff` into the current branch.
304
+ /// Returns Ok(()) on success, or an error with conflict details.
305
+ pub fn merge_no_ff(cwd: &Path, branch: &str) -> Result<()> {
306
+ run(cwd, &["merge", "--no-ff", branch])?;
307
+ Ok(())
308
+ }
309
+
310
+ /// Abort an in-progress merge.
311
+ pub fn merge_abort(cwd: &Path) -> Result<()> {
312
+ let _ = try_run(cwd, &["merge", "--abort"]);
313
+ Ok(())
314
+ }
315
+
316
+ /// Checkout a branch.
317
+ pub fn checkout(cwd: &Path, branch: &str) -> Result<()> {
318
+ run(cwd, &["checkout", branch])?;
319
+ Ok(())
320
+ }
321
+
322
+ /// Stage all changes and commit with message.
323
+ pub fn add_and_commit(cwd: &Path, message: &str) -> Result<()> {
324
+ run(cwd, &["add", "."])?;
325
+ run(cwd, &["commit", "-m", message])?;
326
+ Ok(())
327
+ }
328
+
329
+ /// Push the current branch to origin.
330
+ pub fn push_origin(cwd: &Path, branch: &str) -> Result<()> {
331
+ run(cwd, &["push", "origin", branch])?;
332
+ Ok(())
333
+ }
334
+
335
+ /// List untracked files (respecting .gitignore).
336
+ pub fn untracked_files(cwd: &Path) -> Result<Vec<String>> {
337
+ let output = run(cwd, &["ls-files", "--others", "--exclude-standard"])?;
338
+ if output.is_empty() {
339
+ Ok(Vec::new())
340
+ } else {
341
+ Ok(output.lines().map(|s| s.to_string()).collect())
342
+ }
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // Tests
347
+ // ---------------------------------------------------------------------------
348
+
349
+ #[cfg(test)]
350
+ mod tests {
351
+ use super::*;
352
+ use tempfile::TempDir;
353
+
354
+ fn init_repo() -> TempDir {
355
+ let dir = TempDir::new().unwrap();
356
+ run(dir.path(), &["init"]).unwrap();
357
+ run(dir.path(), &["config", "user.email", "test@test.com"]).unwrap();
358
+ run(dir.path(), &["config", "user.name", "Test"]).unwrap();
359
+ // Create initial commit so branches work
360
+ std::fs::write(dir.path().join("README.md"), "init").unwrap();
361
+ run(dir.path(), &["add", "."]).unwrap();
362
+ run(dir.path(), &["commit", "-m", "init"]).unwrap();
363
+ dir
364
+ }
365
+
366
+ #[test]
367
+ fn is_installed_returns_true() {
368
+ assert!(is_installed());
369
+ }
370
+
371
+ #[test]
372
+ fn is_repo_works() {
373
+ let dir = init_repo();
374
+ assert!(is_repo(dir.path()));
375
+
376
+ let tmp = TempDir::new().unwrap();
377
+ assert!(!is_repo(tmp.path()));
378
+ }
379
+
380
+ #[test]
381
+ fn current_branch_on_main() {
382
+ let dir = init_repo();
383
+ let branch = current_branch(dir.path()).unwrap();
384
+ // Could be "main" or "master" depending on git config
385
+ assert!(branch.is_some());
386
+ }
387
+
388
+ #[test]
389
+ fn slugify_works() {
390
+ assert_eq!(slugify("Hello World"), "hello-world");
391
+ assert_eq!(slugify("Port work CRUD!"), "port-work-crud");
392
+ assert_eq!(slugify("Add feature #42"), "add-feature-42");
393
+ assert_eq!(slugify(" spaces everywhere "), "spaces-everywhere");
394
+ }
395
+
396
+ #[test]
397
+ fn branch_name_for_works() {
398
+ assert_eq!(
399
+ branch_name_for(1199, "Port work item CRUD"),
400
+ "feature/work-1199-port-work-item-crud"
401
+ );
402
+ }
403
+
404
+ #[test]
405
+ fn validate_branch_name_catches_invalid() {
406
+ assert!(validate_branch_name("valid-name").is_ok());
407
+ assert!(validate_branch_name("feature/ok").is_ok());
408
+ assert!(validate_branch_name("").is_err());
409
+ assert!(validate_branch_name("has space").is_err());
410
+ assert!(validate_branch_name("-leading").is_err());
411
+ assert!(validate_branch_name(".leading").is_err());
412
+ assert!(validate_branch_name("bad~char").is_err());
413
+ assert!(validate_branch_name("bad[char").is_err());
414
+ }
415
+
416
+ #[test]
417
+ fn create_or_checkout_branch_works() {
418
+ let dir = init_repo();
419
+ create_or_checkout_branch(dir.path(), "test-branch").unwrap();
420
+ let branch = current_branch(dir.path()).unwrap().unwrap();
421
+ assert_eq!(branch, "test-branch");
422
+
423
+ // Switch back, then checkout existing branch
424
+ run(dir.path(), &["checkout", "master"]).ok();
425
+ run(dir.path(), &["checkout", "main"]).ok();
426
+ create_or_checkout_branch(dir.path(), "test-branch").unwrap();
427
+ let branch = current_branch(dir.path()).unwrap().unwrap();
428
+ assert_eq!(branch, "test-branch");
429
+ }
430
+
431
+ #[test]
432
+ fn status_porcelain_on_clean_repo() {
433
+ let dir = init_repo();
434
+ let status = status_porcelain(dir.path()).unwrap();
435
+ assert!(status.is_empty());
436
+ }
437
+
438
+ #[test]
439
+ fn worktree_add_and_remove() {
440
+ let dir = init_repo();
441
+ let wt_path = dir.path().join("wt-test");
442
+ let default_branch = current_branch(dir.path()).unwrap().unwrap();
443
+
444
+ worktree_add(dir.path(), "wt-branch", &wt_path, &default_branch).unwrap();
445
+ assert!(wt_path.exists());
446
+
447
+ worktree_remove(dir.path(), &wt_path).unwrap();
448
+ assert!(!wt_path.exists());
449
+ }
450
+
451
+ #[test]
452
+ fn untracked_files_lists_new_files() {
453
+ let dir = init_repo();
454
+ std::fs::write(dir.path().join("new.txt"), "hello").unwrap();
455
+ let files = untracked_files(dir.path()).unwrap();
456
+ assert!(files.contains(&"new.txt".to_string()));
457
+ }
458
+ }
@@ -0,0 +1,20 @@
1
+ //! JettyPod core business logic.
2
+ //!
3
+ //! This crate contains all data operations previously handled by the
4
+ //! Node.js sidecar: database access, work item management, git operations,
5
+ //! configuration, auth, sessions, WebSocket broadcasting, and skills sync.
6
+ //!
7
+ //! Used by both the Tauri desktop app and the standalone CLI.
8
+
9
+ pub mod auth;
10
+ pub mod config;
11
+ pub mod db;
12
+ pub mod error;
13
+ pub mod git;
14
+ pub mod sessions;
15
+ pub mod skills;
16
+ pub mod work;
17
+ pub mod worktree;
18
+ pub mod ws;
19
+
20
+ pub use error::{CoreError, Result};