Skip to main content

driven/strategy/
worktree.rs

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}