Skip to main content

track/services/jj_task/
mod.rs

1//! jj-task workspace map integration (reads `~/.config/jj/task-workspaces.json`).
2
3mod map;
4
5use map::load_map;
6use serde::Serialize;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10/// Normalizes a git remote URL to owner/repo (same logic as jj-task.sh).
11pub fn repo_key(repo_path: &str) -> String {
12    let output = Command::new("git")
13        .args(["-C", repo_path, "remote", "get-url", "origin"])
14        .output();
15
16    if let Ok(output) = output {
17        if output.status.success() {
18            let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
19            if let Some(key) = normalize_remote_url(&url) {
20                return key;
21            }
22        }
23    }
24
25    std::fs::canonicalize(repo_path)
26        .unwrap_or_else(|_| PathBuf::from(repo_path))
27        .to_string_lossy()
28        .into_owned()
29}
30
31fn normalize_remote_url(url: &str) -> Option<String> {
32    let stripped = url
33        .trim()
34        .strip_prefix("https://")
35        .or_else(|| url.strip_prefix("git@"))
36        .or_else(|| url.strip_prefix("ssh://"))
37        .unwrap_or(url);
38
39    let stripped = stripped
40        .strip_prefix("github.com:")
41        .or_else(|| stripped.strip_prefix("github.com/"))
42        .unwrap_or(stripped);
43
44    let stripped = stripped.strip_suffix(".git").unwrap_or(stripped);
45    let key = stripped.to_ascii_lowercase();
46    if key.contains('/') && key != url {
47        Some(key)
48    } else {
49        None
50    }
51}
52
53/// Returns the jj-task workspace path for a slug in a repo, if registered.
54pub fn workspace_path(repo_path: &str, slug: &str) -> Option<String> {
55    let map = load_map()?;
56    let key = repo_key(repo_path);
57    map.repos
58        .get(&key)
59        .and_then(|repo| repo.tasks.get(slug))
60        .map(|task| task.workspace.clone())
61}
62
63/// Per-repository jj-task workspace registration for a slug.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct RepoWorkspaceStatus {
66    pub repo_path: String,
67    pub registered: bool,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub workspace_path: Option<String>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub phase: Option<String>,
72}
73
74/// Returns workspace registration status for each repo path.
75pub fn repos_workspace_status(slug: &str, repo_paths: &[String]) -> Vec<RepoWorkspaceStatus> {
76    let map = load_map();
77    repo_paths
78        .iter()
79        .map(|repo_path| {
80            let key = repo_key(repo_path);
81            let entry = map
82                .as_ref()
83                .and_then(|m| m.repos.get(&key))
84                .and_then(|repo| repo.tasks.get(slug));
85            RepoWorkspaceStatus {
86                repo_path: repo_path.clone(),
87                registered: entry.is_some(),
88                workspace_path: entry.map(|task| task.workspace.clone()),
89                phase: entry
90                    .map(|task| task.phase.clone())
91                    .filter(|phase| !phase.is_empty()),
92            }
93        })
94        .collect()
95}
96
97/// True when jj-task has registered the slug in every given repo.
98pub fn all_repos_registered(slug: &str, repo_paths: &[String]) -> bool {
99    if repo_paths.is_empty() {
100        return false;
101    }
102    repos_workspace_status(slug, repo_paths)
103        .iter()
104        .all(|status| status.registered)
105}
106
107/// Repo paths that still need `jj-task start` for this slug.
108pub fn unregistered_repo_paths(slug: &str, repo_paths: &[String]) -> Vec<String> {
109    repos_workspace_status(slug, repo_paths)
110        .into_iter()
111        .filter(|status| !status.registered)
112        .map(|status| status.repo_path)
113        .collect()
114}
115
116/// True when `jj-task repo init` has been run for this repo (repo key exists in map).
117pub fn repo_initialized(repo_path: &str) -> bool {
118    let Some(map) = load_map() else {
119        return false;
120    };
121    map.repos.contains_key(&repo_key(repo_path))
122}
123
124/// Returns registrations that are not marked `done` in the jj-task map.
125pub fn active_registrations(slug: &str, repo_paths: &[String]) -> Vec<RepoWorkspaceStatus> {
126    repos_workspace_status(slug, repo_paths)
127        .into_iter()
128        .filter(|status| status.registered && status.phase.as_deref() != Some("done"))
129        .collect()
130}
131
132/// Workspace paths for active (not done) jj-task registrations.
133pub fn active_workspace_paths(slug: &str, repo_paths: &[String]) -> Vec<String> {
134    active_registrations(slug, repo_paths)
135        .into_iter()
136        .filter_map(|status| status.workspace_path)
137        .collect()
138}
139
140/// Returns true when jj-task has registered the slug for any of the given repo paths.
141pub fn slug_registered(slug: &str, repo_paths: &[String]) -> bool {
142    let Some(map) = load_map() else {
143        return false;
144    };
145
146    repo_paths.iter().any(|repo_path| {
147        let key = repo_key(repo_path);
148        map.repos
149            .get(&key)
150            .and_then(|repo| repo.tasks.get(slug))
151            .is_some()
152    })
153}
154
155/// Returns jj-task phase for a slug in the first matching repo, if any.
156pub fn task_phase(slug: &str, repo_paths: &[String]) -> Option<String> {
157    let map = load_map()?;
158    for repo_path in repo_paths {
159        let key = repo_key(repo_path);
160        if let Some(entry) = map.repos.get(&key).and_then(|repo| repo.tasks.get(slug)) {
161            return Some(entry.phase.clone());
162        }
163    }
164    None
165}
166
167/// Default workspace path following agent-skill-jj layout (before jj-task start).
168pub fn expected_workspace_path(repo_path: &str, slug: &str) -> String {
169    Path::new(repo_path)
170        .join(".worktrees")
171        .join(slug)
172        .to_string_lossy()
173        .into_owned()
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn normalize_github_https_url() {
182        assert_eq!(
183            normalize_remote_url("https://github.com/manji-0/track.git"),
184            Some("manji-0/track".to_string())
185        );
186    }
187
188    #[test]
189    fn normalize_github_ssh_url() {
190        assert_eq!(
191            normalize_remote_url("git@github.com:manji-0/track.git"),
192            Some("manji-0/track".to_string())
193        );
194    }
195
196    #[test]
197    fn active_registrations_excludes_done_phase() {
198        let statuses = vec![
199            RepoWorkspaceStatus {
200                repo_path: "/a".into(),
201                registered: true,
202                workspace_path: Some("/a/.worktrees/slug".into()),
203                phase: Some("done".into()),
204            },
205            RepoWorkspaceStatus {
206                repo_path: "/b".into(),
207                registered: true,
208                workspace_path: Some("/b/.worktrees/slug".into()),
209                phase: Some("active".into()),
210            },
211        ];
212
213        let active: Vec<_> = statuses
214            .into_iter()
215            .filter(|s| s.registered && s.phase.as_deref() != Some("done"))
216            .collect();
217        assert_eq!(active.len(), 1);
218        assert_eq!(active[0].repo_path, "/b");
219    }
220
221    #[test]
222    fn expected_workspace_path_uses_worktrees_dir() {
223        assert_eq!(
224            expected_workspace_path("/repo/app", "fix-auth"),
225            "/repo/app/.worktrees/fix-auth"
226        );
227    }
228}