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,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(¤t_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
|
+
}
|