Skip to main content

apm_core/
worktree.rs

1use anyhow::Result;
2use std::path::{Path, PathBuf};
3use crate::config::Config;
4use crate::git_util::run;
5use crate::ticket::{Ticket, load_all_from_git};
6
7/// Find the directory of an existing permanent worktree for the given branch.
8/// Returns None if no such worktree is registered.
9pub fn find_worktree_for_branch(root: &Path, branch: &str) -> Option<PathBuf> {
10    let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
11    let mut current_path: Option<PathBuf> = None;
12    for line in out.lines() {
13        if let Some(p) = line.strip_prefix("worktree ") {
14            current_path = Some(PathBuf::from(p));
15        } else if let Some(b) = line.strip_prefix("branch refs/heads/") {
16            if b == branch {
17                return current_path;
18            }
19        }
20    }
21    None
22}
23
24/// List all permanent worktrees for ticket/* branches.
25/// Returns (worktree_path, branch_name) pairs, skipping the main worktree.
26pub fn list_ticket_worktrees(root: &Path) -> Result<Vec<(PathBuf, String)>> {
27    let out = run(root, &["worktree", "list", "--porcelain"])?;
28    let main = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
29
30    let mut result = Vec::new();
31    let mut current_path: Option<PathBuf> = None;
32    for line in out.lines() {
33        if let Some(p) = line.strip_prefix("worktree ") {
34            current_path = Some(PathBuf::from(p));
35        } else if let Some(b) = line.strip_prefix("branch refs/heads/") {
36            if b.starts_with("ticket/") {
37                if let Some(p) = &current_path {
38                    if p.canonicalize().unwrap_or_else(|_| p.clone()) != main {
39                        result.push((p.clone(), b.to_string()));
40                    }
41                }
42            }
43        }
44    }
45    Ok(result)
46}
47
48/// Find the worktree for `branch` or create one under `worktrees_base`.
49/// Returns the canonical worktree path. Idempotent.
50pub fn ensure_worktree(root: &Path, worktrees_base: &Path, branch: &str) -> Result<PathBuf> {
51    if let Some(existing) = find_worktree_for_branch(root, branch) {
52        return Ok(existing);
53    }
54    let wt_name = branch.replace('/', "-");
55    std::fs::create_dir_all(worktrees_base)?;
56    let wt_path = worktrees_base.join(&wt_name);
57    add_worktree(root, &wt_path, branch)?;
58    Ok(find_worktree_for_branch(root, branch).unwrap_or(wt_path))
59}
60
61/// Add a permanent worktree for the given branch at wt_path.
62/// Fetches the branch locally first if needed.
63pub fn add_worktree(root: &Path, wt_path: &Path, branch: &str) -> Result<()> {
64    let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
65    if !has_local {
66        let _ = run(root, &["fetch", "origin", branch]);
67    }
68    run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
69    crate::logger::log("add_worktree", &format!("{}", wt_path.display()));
70    Ok(())
71}
72
73/// Remove a permanent worktree.
74pub fn remove_worktree(root: &Path, wt_path: &Path, force: bool) -> Result<()> {
75    clean_agent_dirs(root, wt_path);
76    let path_str = wt_path.to_string_lossy();
77    if force {
78        run(root, &["worktree", "remove", "--force", &path_str]).map(|_| ())
79    } else {
80        run(root, &["worktree", "remove", &path_str]).map(|_| ())
81    }
82}
83
84/// Copy agent config directories from the main repo into a worktree.
85/// Only copies directories that are NOT tracked by git (untracked/gitignored).
86pub fn sync_agent_dirs(root: &Path, wt_path: &Path, agent_dirs: &[String], warnings: &mut Vec<String>) {
87    for dir_name in agent_dirs {
88        let src = root.join(dir_name);
89        if !src.is_dir() {
90            continue;
91        }
92        if is_tracked(root, dir_name) {
93            continue;
94        }
95        let dst = wt_path.join(dir_name);
96        if let Err(e) = copy_dir_recursive(&src, &dst) {
97            warnings.push(format!("warning: could not copy {dir_name} to worktree: {e}"));
98        }
99    }
100}
101
102/// Remove agent config directories from a worktree before cleanup.
103/// Only removes directories that are NOT tracked by git.
104fn clean_agent_dirs(root: &Path, wt_path: &Path) {
105    let config = match Config::load(root) {
106        Ok(c) => c,
107        Err(_) => return,
108    };
109    for dir_name in &config.worktrees.agent_dirs {
110        let dir = wt_path.join(dir_name);
111        if !dir.is_dir() {
112            continue;
113        }
114        if is_tracked(root, dir_name) {
115            continue;
116        }
117        let _ = std::fs::remove_dir_all(&dir);
118    }
119}
120
121fn is_tracked(root: &Path, path: &str) -> bool {
122    crate::git_util::is_file_tracked(root, path)
123}
124
125fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
126    if dst.exists() {
127        std::fs::remove_dir_all(dst)?;
128    }
129    std::fs::create_dir_all(dst)?;
130    for entry in std::fs::read_dir(src)? {
131        let entry = entry?;
132        let src_path = entry.path();
133        let dst_path = dst.join(entry.file_name());
134        if src_path.is_dir() {
135            copy_dir_recursive(&src_path, &dst_path)?;
136        } else {
137            std::fs::copy(&src_path, &dst_path)?;
138        }
139    }
140    Ok(())
141}
142
143pub fn provision_worktree(root: &Path, config: &Config, branch: &str, warnings: &mut Vec<String>) -> Result<PathBuf> {
144    let main_root = crate::git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
145    let worktrees_base = main_root.join(&config.worktrees.dir);
146    let wt = ensure_worktree(root, &worktrees_base, branch)?;
147    sync_agent_dirs(root, &wt, &config.worktrees.agent_dirs, warnings);
148    Ok(wt)
149}
150
151#[cfg(test)]
152mod tests {
153    use std::process::Command;
154    use tempfile::TempDir;
155
156    fn git_init(dir: &std::path::Path) {
157        Command::new("git").args(["init", "-b", "main"]).current_dir(dir).output().unwrap();
158        Command::new("git").args(["config", "user.email", "t@t.com"]).current_dir(dir).output().unwrap();
159        Command::new("git").args(["config", "user.name", "test"]).current_dir(dir).output().unwrap();
160    }
161
162    #[test]
163    fn provision_worktree_creates_dir_inside_repo() {
164        let tmp = TempDir::new().unwrap();
165        let repo = tmp.path();
166        git_init(repo);
167        std::fs::write(repo.join("README"), "x").unwrap();
168        Command::new("git").args(["-c", "commit.gpgsign=false", "add", "README"]).current_dir(repo).output().unwrap();
169        Command::new("git").args(["-c", "commit.gpgsign=false", "commit", "-m", "init"]).current_dir(repo).output().unwrap();
170        Command::new("git").args(["branch", "ticket/test-branch"]).current_dir(repo).output().unwrap();
171
172        let toml = r#"[project]
173name = "test"
174
175[tickets]
176dir = "tickets"
177
178[worktrees]
179dir = "worktrees"
180"#;
181        let config: crate::config::Config = toml::from_str(toml).unwrap();
182
183        let mut warnings: Vec<String> = Vec::new();
184        let wt = super::provision_worktree(repo, &config, "ticket/test-branch", &mut warnings).unwrap();
185
186        let main_root = crate::git_util::main_worktree_root(repo)
187            .unwrap_or_else(|| repo.to_path_buf());
188        let expected = main_root.join("worktrees").join("ticket-test-branch");
189        assert_eq!(wt, expected, "provisioned path must be <repo>/worktrees/<branch-slug>");
190        assert!(wt.is_dir(), "provisioned worktree dir must exist on disk: {}", wt.display());
191        assert!(
192            wt.starts_with(&main_root),
193            "worktree path must be inside repo: wt={} repo={}",
194            wt.display(),
195            main_root.display()
196        );
197    }
198
199    #[test]
200    fn provision_worktree_honours_external_layout() {
201        // Existing repos with `dir = "../<name>--worktrees"` must keep working
202        // — the external layout is still supported, just no longer the default.
203        let tmp = TempDir::new().unwrap();
204        let repo = tmp.path().join("repo");
205        std::fs::create_dir_all(&repo).unwrap();
206        git_init(&repo);
207        std::fs::write(repo.join("README"), "x").unwrap();
208        Command::new("git").args(["-c", "commit.gpgsign=false", "add", "README"]).current_dir(&repo).output().unwrap();
209        Command::new("git").args(["-c", "commit.gpgsign=false", "commit", "-m", "init"]).current_dir(&repo).output().unwrap();
210        Command::new("git").args(["branch", "ticket/ext-branch"]).current_dir(&repo).output().unwrap();
211
212        let toml = r#"[project]
213name = "test"
214
215[tickets]
216dir = "tickets"
217
218[worktrees]
219dir = "../external-worktrees"
220"#;
221        let config: crate::config::Config = toml::from_str(toml).unwrap();
222
223        let mut warnings: Vec<String> = Vec::new();
224        let wt = super::provision_worktree(&repo, &config, "ticket/ext-branch", &mut warnings).unwrap();
225
226        let expected = tmp.path().join("external-worktrees").join("ticket-ext-branch");
227        assert_eq!(
228            wt.canonicalize().unwrap(),
229            expected.canonicalize().unwrap(),
230            "external layout must place worktree as a sibling of the repo"
231        );
232        assert!(wt.is_dir(), "external worktree dir must exist on disk: {}", wt.display());
233    }
234}
235
236pub fn list_worktrees_with_tickets(
237    root: &Path,
238    tickets_dir: &Path,
239) -> Result<Vec<(PathBuf, String, Option<Ticket>)>> {
240    let worktrees = list_ticket_worktrees(root)?;
241    let tickets = load_all_from_git(root, tickets_dir).unwrap_or_default();
242    let result = worktrees.into_iter().map(|(wt_path, branch)| {
243        let ticket = tickets.iter().find(|t| {
244            t.frontmatter.branch.as_deref() == Some(branch.as_str())
245                || crate::ticket_fmt::branch_name_from_path(&t.path).as_deref() == Some(branch.as_str())
246        }).cloned();
247        (wt_path, branch, ticket)
248    }).collect();
249    Ok(result)
250}