1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum GitCheckoutKind {
8 NotRepository,
9 MainWorktree,
10 LinkedWorktree,
11 Submodule,
12 Bare,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum WorktreeIsolationMode {
18 NoGitRepository,
19 MainWorktree,
20 LinkedWorktree,
21 Submodule,
22 BareRepository,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum WorktreeCreationDecision {
28 Blocked,
29 CreateGitWorktree,
30 ReuseCurrentWorktree,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct WorktreeMetadata {
35 pub kind: GitCheckoutKind,
36 pub input_root: PathBuf,
37 pub worktree_root: Option<PathBuf>,
38 pub git_dir: Option<PathBuf>,
39 pub common_dir: Option<PathBuf>,
40 pub superproject_root: Option<PathBuf>,
41 pub branch: Option<String>,
42 pub detached: bool,
43 pub head_sha: Option<String>,
44 pub remote: Option<String>,
45 pub is_dirty: Option<bool>,
46 pub ahead: Option<u32>,
47 pub behind: Option<u32>,
48 pub detection_error: Option<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct WorktreeIdentity {
53 pub kind: GitCheckoutKind,
54 pub input_root: PathBuf,
55 pub worktree_root: Option<PathBuf>,
56 pub git_dir: Option<PathBuf>,
57 pub common_dir: Option<PathBuf>,
58 pub superproject_root: Option<PathBuf>,
59 pub branch: Option<String>,
60 pub remote: Option<String>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct WorktreeIsolationPlan {
65 pub schema: String,
66 pub metadata: WorktreeMetadata,
67 pub mode: WorktreeIsolationMode,
68 pub creation_decision: WorktreeCreationDecision,
69 pub can_create_git_worktree: bool,
70 pub native_isolation_detected: bool,
71 pub execution_root: Option<PathBuf>,
72 pub blockers: Vec<String>,
73 pub warnings: Vec<String>,
74}
75
76impl WorktreeMetadata {
77 pub fn is_not_repository(&self) -> bool {
78 self.kind == GitCheckoutKind::NotRepository
79 }
80
81 pub fn identity(&self) -> WorktreeIdentity {
82 WorktreeIdentity::from_metadata(self)
83 }
84
85 pub fn has_same_identity(&self, other: &Self) -> bool {
86 self.identity() == other.identity()
87 }
88}
89
90impl WorktreeIdentity {
91 pub fn from_metadata(metadata: &WorktreeMetadata) -> Self {
92 let input_root = metadata
93 .worktree_root
94 .clone()
95 .unwrap_or_else(|| metadata.input_root.clone());
96 Self {
97 kind: metadata.kind,
98 input_root,
99 worktree_root: metadata.worktree_root.clone(),
100 git_dir: metadata.git_dir.clone(),
101 common_dir: metadata.common_dir.clone(),
102 superproject_root: metadata.superproject_root.clone(),
103 branch: metadata.branch.clone(),
104 remote: metadata.remote.clone(),
105 }
106 }
107}
108
109impl WorktreeIsolationPlan {
110 pub fn from_metadata(metadata: WorktreeMetadata) -> Self {
111 let mut blockers = Vec::new();
112 let mut warnings = Vec::new();
113
114 let (mode, creation_decision, can_create_git_worktree, native_isolation_detected) =
115 match metadata.kind {
116 GitCheckoutKind::NotRepository => {
117 blockers.push(
118 "path is not a Git repository; git worktree creation is blocked"
119 .to_string(),
120 );
121 if let Some(error) = &metadata.detection_error {
122 blockers.push(error.clone());
123 }
124 (
125 WorktreeIsolationMode::NoGitRepository,
126 WorktreeCreationDecision::Blocked,
127 false,
128 false,
129 )
130 }
131 GitCheckoutKind::Bare => {
132 blockers.push(
133 "bare repository has no working tree; provide an explicit checkout target before isolation"
134 .to_string(),
135 );
136 (
137 WorktreeIsolationMode::BareRepository,
138 WorktreeCreationDecision::Blocked,
139 false,
140 false,
141 )
142 }
143 GitCheckoutKind::LinkedWorktree => {
144 if metadata.is_dirty == Some(true) {
145 warnings.push(
146 "linked worktree has local changes; confirm ownership before reuse"
147 .to_string(),
148 );
149 }
150 if metadata.detached {
151 warnings.push(
152 "linked worktree is detached; preserve external orchestration metadata"
153 .to_string(),
154 );
155 }
156 (
157 WorktreeIsolationMode::LinkedWorktree,
158 WorktreeCreationDecision::ReuseCurrentWorktree,
159 false,
160 true,
161 )
162 }
163 GitCheckoutKind::Submodule => {
164 warnings.push(
165 "path is a Git submodule; reuse current checkout unless the superproject owns orchestration"
166 .to_string(),
167 );
168 if metadata.is_dirty == Some(true) {
169 warnings.push(
170 "submodule has local changes; confirm ownership before reuse"
171 .to_string(),
172 );
173 }
174 (
175 WorktreeIsolationMode::Submodule,
176 WorktreeCreationDecision::ReuseCurrentWorktree,
177 false,
178 true,
179 )
180 }
181 GitCheckoutKind::MainWorktree => {
182 if metadata.is_dirty == Some(true) {
183 blockers.push(
184 "main worktree has local changes; commit, stash, or claim an existing isolated worktree before creating isolation"
185 .to_string(),
186 );
187 }
188 if metadata.detached {
189 blockers.push(
190 "main worktree must be on a branch with a resolved HEAD before creating a git worktree"
191 .to_string(),
192 );
193 }
194 if metadata.head_sha.is_none() {
195 blockers.push(
196 "main worktree must have a branch with a resolved HEAD before creating a git worktree"
197 .to_string(),
198 );
199 }
200 let can_create = blockers.is_empty();
201 (
202 WorktreeIsolationMode::MainWorktree,
203 if can_create {
204 WorktreeCreationDecision::CreateGitWorktree
205 } else {
206 WorktreeCreationDecision::Blocked
207 },
208 can_create,
209 false,
210 )
211 }
212 };
213
214 let execution_root = match mode {
215 WorktreeIsolationMode::NoGitRepository | WorktreeIsolationMode::BareRepository => None,
216 _ => metadata.worktree_root.clone(),
217 };
218
219 Self {
220 schema: "driven.worktree_isolation_plan.v1".to_string(),
221 metadata,
222 mode,
223 creation_decision,
224 can_create_git_worktree,
225 native_isolation_detected,
226 execution_root,
227 blockers,
228 warnings,
229 }
230 }
231}
232
233pub fn plan_worktree_isolation(root: &Path) -> WorktreeIsolationPlan {
234 WorktreeIsolationPlan::from_metadata(detect_worktree_metadata(root))
235}
236
237pub fn detect_worktree_metadata(root: &Path) -> WorktreeMetadata {
238 let input_root = normalize_worktree_root(root);
239 let root = input_root.as_path();
240 let worktree_root = match git(root, &["rev-parse", "--show-toplevel"]) {
241 Ok(value) => PathBuf::from(value),
242 Err(error) => {
243 if git(root, &["rev-parse", "--is-bare-repository"])
244 .map(|value| value == "true")
245 .unwrap_or(false)
246 {
247 return bare_metadata(root, input_root.clone());
248 }
249
250 return WorktreeMetadata {
251 kind: GitCheckoutKind::NotRepository,
252 input_root,
253 worktree_root: None,
254 git_dir: None,
255 common_dir: None,
256 superproject_root: None,
257 branch: None,
258 detached: false,
259 head_sha: None,
260 remote: None,
261 is_dirty: None,
262 ahead: None,
263 behind: None,
264 detection_error: Some(error),
265 };
266 }
267 };
268
269 let bare = git(root, &["rev-parse", "--is-bare-repository"])
270 .map(|value| value == "true")
271 .unwrap_or(false);
272 let git_dir = git(root, &["rev-parse", "--git-dir"])
273 .ok()
274 .map(|path| normalize_git_path(root, path));
275 let common_dir = git(root, &["rev-parse", "--git-common-dir"])
276 .ok()
277 .map(|path| normalize_git_path(root, path));
278 let superproject_root = git(root, &["rev-parse", "--show-superproject-working-tree"])
279 .ok()
280 .filter(|value| !value.is_empty())
281 .map(PathBuf::from);
282
283 let kind = if bare {
284 GitCheckoutKind::Bare
285 } else if superproject_root.is_some() {
286 GitCheckoutKind::Submodule
287 } else if git_dir.is_some() && common_dir.is_some() && git_dir != common_dir {
288 GitCheckoutKind::LinkedWorktree
289 } else {
290 GitCheckoutKind::MainWorktree
291 };
292
293 let branch = git(root, &["rev-parse", "--abbrev-ref", "HEAD"])
294 .ok()
295 .filter(|value| value != "HEAD");
296 let detached = branch.is_none();
297 let head_sha = git(root, &["rev-parse", "HEAD"]).ok();
298 let remote = git(root, &["config", "--get", "remote.origin.url"]).ok();
299 let is_dirty = git(root, &["status", "--porcelain"])
300 .ok()
301 .map(|value| !value.trim().is_empty());
302 let (ahead, behind) = ahead_behind(root);
303
304 WorktreeMetadata {
305 kind,
306 input_root,
307 worktree_root: Some(worktree_root),
308 git_dir,
309 common_dir,
310 superproject_root,
311 branch,
312 detached,
313 head_sha,
314 remote,
315 is_dirty,
316 ahead,
317 behind,
318 detection_error: None,
319 }
320}
321
322fn bare_metadata(root: &Path, input_root: PathBuf) -> WorktreeMetadata {
323 let git_dir = git(root, &["rev-parse", "--git-dir"])
324 .ok()
325 .map(|path| normalize_git_path(root, path));
326 let common_dir = git(root, &["rev-parse", "--git-common-dir"])
327 .ok()
328 .map(|path| normalize_git_path(root, path));
329 let branch = git(root, &["rev-parse", "--abbrev-ref", "HEAD"])
330 .ok()
331 .filter(|value| value != "HEAD");
332
333 WorktreeMetadata {
334 kind: GitCheckoutKind::Bare,
335 input_root,
336 worktree_root: None,
337 git_dir,
338 common_dir,
339 superproject_root: None,
340 branch: branch.clone(),
341 detached: branch.is_none(),
342 head_sha: git(root, &["rev-parse", "HEAD"]).ok(),
343 remote: git(root, &["config", "--get", "remote.origin.url"]).ok(),
344 is_dirty: None,
345 ahead: None,
346 behind: None,
347 detection_error: None,
348 }
349}
350
351pub(crate) fn normalize_worktree_root(root: &Path) -> PathBuf {
352 if let Ok(canonical) = root.canonicalize() {
353 return normalize_platform_path(canonical);
354 }
355 if root.is_absolute() {
356 return normalize_platform_path(root.to_path_buf());
357 }
358 let absolute = std::env::current_dir()
359 .map(|cwd| cwd.join(root))
360 .unwrap_or_else(|_| root.to_path_buf());
361 normalize_platform_path(absolute)
362}
363
364#[cfg(windows)]
365fn normalize_platform_path(path: PathBuf) -> PathBuf {
366 let path_text = path.to_string_lossy();
367 if let Some(rest) = path_text.strip_prefix(r"\\?\UNC\") {
368 return PathBuf::from(format!(r"\\{}", rest));
369 }
370 if let Some(rest) = path_text.strip_prefix(r"\\?\") {
371 return PathBuf::from(rest);
372 }
373 path
374}
375
376#[cfg(not(windows))]
377fn normalize_platform_path(path: PathBuf) -> PathBuf {
378 path
379}
380
381fn normalize_git_path(root: &Path, path: String) -> PathBuf {
382 let path = PathBuf::from(path);
383 if path.is_absolute() {
384 path
385 } else {
386 root.join(path)
387 }
388}
389
390fn ahead_behind(root: &Path) -> (Option<u32>, Option<u32>) {
391 let output = match git(
392 root,
393 &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
394 ) {
395 Ok(output) => output,
396 Err(_) => return (None, None),
397 };
398
399 let mut parts = output.split_whitespace();
400 let behind = parts.next().and_then(|value| value.parse().ok());
401 let ahead = parts.next().and_then(|value| value.parse().ok());
402 (ahead, behind)
403}
404
405fn git(root: &Path, args: &[&str]) -> Result<String, String> {
406 let output = Command::new("git")
407 .args(args)
408 .current_dir(root)
409 .output()
410 .map_err(|e| format!("failed to run git: {}", e))?;
411
412 if output.status.success() {
413 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
414 } else {
415 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
416 if stderr.is_empty() {
417 Err(format!("git {:?} exited with {}", args, output.status))
418 } else {
419 Err(stderr)
420 }
421 }
422}