Skip to main content

tandem_server/runtime/
worktrees.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6pub struct ManagedWorktreeRecord {
7    pub key: String,
8    pub repo_root: String,
9    pub path: String,
10    pub branch: String,
11    pub base: String,
12    pub managed: bool,
13    pub task_id: Option<String>,
14    pub owner_run_id: Option<String>,
15    pub lease_id: Option<String>,
16    pub cleanup_branch: bool,
17    pub created_at_ms: u64,
18    pub updated_at_ms: u64,
19}
20
21#[derive(Debug, Clone)]
22pub struct ManagedWorktreeEnsureInput {
23    pub repo_root: String,
24    pub task_id: Option<String>,
25    pub owner_run_id: Option<String>,
26    pub lease_id: Option<String>,
27    pub branch_hint: Option<String>,
28    pub base: String,
29    pub cleanup_branch: bool,
30}
31
32#[derive(Debug, Clone)]
33pub struct ManagedWorktreeEnsureResult {
34    pub record: ManagedWorktreeRecord,
35    pub reused: bool,
36}
37
38fn slug_part(raw: Option<&str>) -> Option<String> {
39    let cleaned = raw
40        .unwrap_or_default()
41        .trim()
42        .chars()
43        .map(|ch| {
44            if ch.is_ascii_alphanumeric() {
45                ch.to_ascii_lowercase()
46            } else {
47                '-'
48            }
49        })
50        .collect::<String>();
51    let collapsed = cleaned
52        .split('-')
53        .filter(|part| !part.is_empty())
54        .collect::<Vec<_>>()
55        .join("-");
56    if collapsed.is_empty() {
57        None
58    } else {
59        Some(collapsed)
60    }
61}
62
63pub fn managed_worktree_slug(
64    task_id: Option<&str>,
65    owner_run_id: Option<&str>,
66    lease_id: Option<&str>,
67    branch_hint: Option<&str>,
68) -> String {
69    let mut parts = Vec::new();
70    if let Some(task_id) = slug_part(task_id) {
71        parts.push(task_id);
72    }
73    if let Some(owner_run_id) = slug_part(owner_run_id) {
74        parts.push(owner_run_id);
75    }
76    if let Some(lease_id) = slug_part(lease_id) {
77        parts.push(lease_id);
78    }
79    if parts.is_empty() {
80        parts.push(
81            slug_part(branch_hint)
82                .filter(|value| !value.is_empty())
83                .unwrap_or_else(|| "worktree".to_string()),
84        );
85    }
86    parts.join("-")
87}
88
89pub fn managed_worktree_key(
90    repo_root: &str,
91    task_id: Option<&str>,
92    owner_run_id: Option<&str>,
93    lease_id: Option<&str>,
94    path: &str,
95    branch: &str,
96) -> String {
97    let task_id = task_id.unwrap_or("");
98    let owner_run_id = owner_run_id.unwrap_or("");
99    let lease_id = lease_id.unwrap_or("");
100    format!("{repo_root}::{task_id}::{owner_run_id}::{lease_id}::{path}::{branch}")
101}
102
103pub fn managed_worktree_root(repo_root: &str) -> PathBuf {
104    PathBuf::from(repo_root).join(".tandem").join("worktrees")
105}
106
107pub fn managed_worktree_path(repo_root: &str, slug: &str) -> PathBuf {
108    managed_worktree_root(repo_root).join(slug)
109}
110
111pub fn is_within_managed_worktree_root(repo_root: &str, path: &Path) -> bool {
112    path.starts_with(managed_worktree_root(repo_root))
113}
114
115pub fn resolve_git_repo_root(candidate: &str) -> Option<String> {
116    let output = std::process::Command::new("git")
117        .args(["-C", candidate, "rev-parse", "--show-toplevel"])
118        .output()
119        .ok()?;
120    if !output.status.success() {
121        return None;
122    }
123    let resolved = String::from_utf8_lossy(&output.stdout).trim().to_string();
124    crate::normalize_absolute_workspace_root(&resolved).ok()
125}
126
127pub async fn ensure_managed_worktree(
128    state: &crate::AppState,
129    input: ManagedWorktreeEnsureInput,
130) -> anyhow::Result<ManagedWorktreeEnsureResult> {
131    let slug = managed_worktree_slug(
132        input.task_id.as_deref(),
133        input.owner_run_id.as_deref(),
134        input.lease_id.as_deref(),
135        input.branch_hint.as_deref(),
136    );
137    let path = managed_worktree_path(&input.repo_root, &slug);
138    let branch = format!("tandem/{slug}");
139    let path_string = path.to_string_lossy().to_string();
140    let key = managed_worktree_key(
141        &input.repo_root,
142        input.task_id.as_deref(),
143        input.owner_run_id.as_deref(),
144        input.lease_id.as_deref(),
145        &path_string,
146        &branch,
147    );
148    if let Some(existing) = state.managed_worktrees.read().await.get(&key).cloned() {
149        if worktree_is_registered(&input.repo_root, &existing.path)? {
150            return Ok(ManagedWorktreeEnsureResult {
151                record: existing,
152                reused: true,
153            });
154        }
155    }
156    if let Some(parent) = path.parent() {
157        std::fs::create_dir_all(parent)?;
158    }
159    if path.exists() && !worktree_is_registered(&input.repo_root, &path_string)? {
160        anyhow::bail!("managed worktree path conflict: {path_string}");
161    }
162    let now = crate::now_ms();
163    if worktree_is_registered(&input.repo_root, &path_string)? {
164        let record = ManagedWorktreeRecord {
165            key: key.clone(),
166            repo_root: input.repo_root.clone(),
167            path: path_string,
168            branch,
169            base: input.base,
170            managed: true,
171            task_id: input.task_id,
172            owner_run_id: input.owner_run_id,
173            lease_id: input.lease_id,
174            cleanup_branch: input.cleanup_branch,
175            created_at_ms: now,
176            updated_at_ms: now,
177        };
178        state
179            .managed_worktrees
180            .write()
181            .await
182            .insert(key, record.clone());
183        return Ok(ManagedWorktreeEnsureResult {
184            record,
185            reused: true,
186        });
187    }
188    let output = std::process::Command::new("git")
189        .args([
190            "-C",
191            &input.repo_root,
192            "worktree",
193            "add",
194            "-b",
195            &branch,
196            &path.to_string_lossy(),
197            &input.base,
198        ])
199        .output()?;
200    if !output.status.success() {
201        anyhow::bail!(
202            "git worktree add failed: {}",
203            String::from_utf8_lossy(&output.stderr).trim()
204        );
205    }
206    let record = ManagedWorktreeRecord {
207        key: key.clone(),
208        repo_root: input.repo_root,
209        path: path.to_string_lossy().to_string(),
210        branch,
211        base: input.base,
212        managed: true,
213        task_id: input.task_id,
214        owner_run_id: input.owner_run_id,
215        lease_id: input.lease_id,
216        cleanup_branch: input.cleanup_branch,
217        created_at_ms: now,
218        updated_at_ms: now,
219    };
220    state
221        .managed_worktrees
222        .write()
223        .await
224        .insert(key, record.clone());
225    Ok(ManagedWorktreeEnsureResult {
226        record,
227        reused: false,
228    })
229}
230
231pub async fn delete_managed_worktree(
232    state: &crate::AppState,
233    record: &ManagedWorktreeRecord,
234) -> anyhow::Result<()> {
235    let output = std::process::Command::new("git")
236        .args([
237            "-C",
238            &record.repo_root,
239            "worktree",
240            "remove",
241            "--force",
242            &record.path,
243        ])
244        .output()?;
245    if !output.status.success() {
246        anyhow::bail!(
247            "git worktree remove failed: {}",
248            String::from_utf8_lossy(&output.stderr).trim()
249        );
250    }
251    if record.cleanup_branch {
252        let _ = std::process::Command::new("git")
253            .args(["-C", &record.repo_root, "branch", "-D", &record.branch])
254            .output();
255    }
256    state
257        .managed_worktrees
258        .write()
259        .await
260        .retain(|_, row| !(row.repo_root == record.repo_root && row.path == record.path));
261    Ok(())
262}
263
264fn worktree_is_registered(repo_root: &str, path: &str) -> anyhow::Result<bool> {
265    let output = std::process::Command::new("git")
266        .args(["-C", repo_root, "worktree", "list", "--porcelain"])
267        .output()?;
268    if !output.status.success() {
269        return Ok(false);
270    }
271    let needle = PathBuf::from(path);
272    for line in String::from_utf8_lossy(&output.stdout).lines() {
273        if let Some(value) = line.strip_prefix("worktree ") {
274            if PathBuf::from(value) == needle {
275                return Ok(true);
276            }
277        }
278    }
279    Ok(false)
280}