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}