Skip to main content

tms/
repos.rs

1use aho_corasick::{AhoCorasickBuilder, MatchKind};
2use error_stack::{report, Report, ResultExt};
3use gix::{Repository, Submodule};
4use jj_lib::{
5    config::StackedConfig,
6    git_backend::GitBackend,
7    local_working_copy::{LocalWorkingCopy, LocalWorkingCopyFactory},
8    repo::StoreFactories,
9    settings::UserSettings,
10    workspace::{WorkingCopyFactories, Workspace},
11    workspace_store::{SimpleWorkspaceStore, WorkspaceStore},
12};
13use once_cell::sync::OnceCell;
14use std::{
15    collections::{HashMap, VecDeque},
16    fs::{self},
17    path::{Path, PathBuf},
18    process::{self, Stdio},
19};
20
21use crate::{
22    configs::{Config, SearchDirectory, VcsProviders, DEFAULT_VCS_PROVIDERS},
23    dirty_paths::DirtyUtf8Path,
24    session::{Session, SessionContainer, SessionType},
25    Result, TmsError,
26};
27
28pub trait Worktree {
29    fn name(&self) -> String;
30
31    fn path(&self) -> Result<PathBuf>;
32
33    fn is_prunable(&self) -> bool;
34}
35
36impl Worktree for gix::worktree::Proxy<'_> {
37    fn name(&self) -> String {
38        self.id().to_string()
39    }
40
41    fn path(&self) -> Result<PathBuf> {
42        self.base().change_context(TmsError::GitError)
43    }
44
45    fn is_prunable(&self) -> bool {
46        !self.base().is_ok_and(|path| path.exists())
47    }
48}
49
50impl Worktree for Workspace {
51    fn name(&self) -> String {
52        self.working_copy().workspace_name().as_str().to_string()
53    }
54
55    fn path(&self) -> Result<PathBuf> {
56        Ok(self.workspace_root().to_path_buf())
57    }
58
59    fn is_prunable(&self) -> bool {
60        false
61    }
62}
63
64impl VcsProviders {
65    pub fn new<'a, I>(path: &Path, providers: I) -> Result<Self>
66    where
67        I: IntoIterator<Item = &'a VcsProviders>,
68    {
69        providers
70            .into_iter()
71            .filter_map(|provider| match provider {
72                VcsProviders::Git => {
73                    let mut flags = 0u8;
74                    for entry in path.read_dir().ok()? {
75                        let entry = entry.ok()?;
76                        let name = entry.file_name();
77                        let file_type = entry.file_type().ok()?;
78
79                        match name.to_str() {
80                            Some(".git") => {
81                                return Some(VcsProviders::Git);
82                            }
83                            Some("HEAD") if file_type.is_file() => flags |= 0b001,
84                            Some("objects") if file_type.is_dir() => flags |= 0b010,
85                            Some("refs") if file_type.is_dir() => flags |= 0b100,
86                            _ => {}
87                        }
88                        if flags == 0b111 {
89                            return Some(VcsProviders::Git);
90                        }
91                    }
92                    None
93                }
94                VcsProviders::Jujutsu => path
95                    .join(".jj/repo")
96                    .exists()
97                    .then_some(VcsProviders::Jujutsu),
98            })
99            .next()
100            .ok_or(TmsError::GitError)
101            .attach_printable_lazy(|| format!("No repo found in {:#?}", path))
102    }
103
104    pub fn open(&self, path: &Path) -> Result<RepoProvider> {
105        match self {
106            VcsProviders::Git => gix::open(path)
107                .map(|repo| RepoProvider::Git(Box::new(repo)))
108                .change_context(TmsError::GitError),
109            VcsProviders::Jujutsu => {
110                let user_settings = UserSettings::from_config(StackedConfig::with_defaults())
111                    .change_context(TmsError::GitError)?;
112                let mut store_factories = StoreFactories::default();
113                store_factories.add_backend(
114                    GitBackend::name(),
115                    Box::new(|settings, store_path| {
116                        Ok(Box::new(GitBackend::load(settings, store_path)?))
117                    }),
118                );
119                let mut working_copy_factories = WorkingCopyFactories::new();
120                working_copy_factories.insert(
121                    LocalWorkingCopy::name().to_owned(),
122                    Box::new(LocalWorkingCopyFactory {}),
123                );
124
125                Workspace::load(
126                    &user_settings,
127                    path,
128                    &store_factories,
129                    &working_copy_factories,
130                )
131                .map(RepoProvider::Jujutsu)
132                .change_context(TmsError::GitError)
133            }
134        }
135    }
136}
137
138pub struct LazyRepoProvider {
139    pub path: PathBuf,
140    pub provider: VcsProviders,
141    resolved: OnceCell<RepoProvider>,
142}
143
144impl LazyRepoProvider {
145    pub fn new<'a, I>(path: &Path, providers: I) -> Result<Self>
146    where
147        I: IntoIterator<Item = &'a VcsProviders>,
148    {
149        let provider = VcsProviders::new(path, providers)?;
150        Ok(Self {
151            path: path.to_path_buf(),
152            provider,
153            resolved: OnceCell::new(),
154        })
155    }
156
157    pub fn new_resolved(path: &Path, provider: VcsProviders, repo: RepoProvider) -> Self {
158        Self {
159            path: path.to_path_buf(),
160            provider,
161            resolved: OnceCell::with_value(repo),
162        }
163    }
164
165    pub fn resolve(&self) -> Result<&RepoProvider> {
166        self.resolved
167            .get_or_try_init(|| self.provider.open(&self.path))
168    }
169
170    pub fn is_worktree(&self) -> Result<bool> {
171        match self.provider {
172            VcsProviders::Git => {
173                if !self.path.join(".git").is_file() {
174                    return Ok(false);
175                }
176                let repo = self.resolve()?;
177                Ok(repo.is_worktree())
178            }
179            VcsProviders::Jujutsu => Ok(self.path.join(".jj/repo").is_file()),
180        }
181    }
182}
183
184pub enum RepoProvider {
185    Git(Box<Repository>),
186    Jujutsu(Workspace),
187}
188
189impl From<gix::Repository> for RepoProvider {
190    fn from(repo: gix::Repository) -> Self {
191        Self::Git(Box::new(repo))
192    }
193}
194
195impl RepoProvider {
196    pub fn open(path: &Path, config: &Config) -> Result<Self> {
197        let vcs_provider_config = config
198            .vcs_providers
199            .clone()
200            .unwrap_or_else(|| DEFAULT_VCS_PROVIDERS.to_vec());
201        let provider = VcsProviders::new(path, &vcs_provider_config)?;
202        provider.open(path)
203    }
204
205    pub fn is_worktree(&self) -> bool {
206        match self {
207            RepoProvider::Git(repo) => {
208                matches!(
209                    repo.kind(),
210                    gix::repository::Kind::WorkTree { is_linked: true }
211                )
212            }
213            RepoProvider::Jujutsu(repo) => {
214                let repo_path = repo.repo_path();
215                let workspace_repo_path = repo.workspace_root().join(".jj/repo");
216                repo_path != workspace_repo_path
217            }
218        }
219    }
220
221    pub fn path(&self) -> &Path {
222        match self {
223            RepoProvider::Git(repo) => repo.path(),
224            RepoProvider::Jujutsu(repo) => repo.workspace_root(),
225        }
226    }
227
228    pub fn main_repo(&self) -> Option<PathBuf> {
229        match self {
230            RepoProvider::Git(repo) => repo.main_repo().map(|repo| repo.path().to_path_buf()).ok(),
231            RepoProvider::Jujutsu(repo) => Some(repo.repo_path().to_path_buf()),
232        }
233    }
234
235    pub fn work_dir(&self) -> Option<&Path> {
236        match self {
237            RepoProvider::Git(repo) => repo.workdir(),
238            RepoProvider::Jujutsu(repo) => Some(repo.workspace_root()),
239        }
240    }
241
242    pub fn head_name(&self) -> Result<String> {
243        match self {
244            RepoProvider::Git(repo) => Ok(repo
245                .head_name()
246                .change_context(TmsError::GitError)?
247                .ok_or(TmsError::GitError)?
248                .shorten()
249                .to_string()),
250            RepoProvider::Jujutsu(_) => Err(TmsError::GitError.into()),
251        }
252    }
253    pub fn submodules(&'_ self) -> Result<Option<impl Iterator<Item = Submodule<'_>>>> {
254        match self {
255            RepoProvider::Git(repo) => repo.submodules().change_context(TmsError::GitError),
256            RepoProvider::Jujutsu(_) => Ok(None),
257        }
258    }
259
260    pub fn is_bare(&self) -> bool {
261        match self {
262            RepoProvider::Git(repo) => repo.is_bare(),
263            RepoProvider::Jujutsu(workspace) => {
264                let loader = workspace.repo_loader();
265                let store = loader.store();
266                let Ok(repo) = loader.load_at_head() else {
267                    return false;
268                };
269                // currently checked out commit, get from current (default) workspace
270                let Some(commit_id) = repo.view().wc_commit_ids().get(workspace.workspace_name())
271                else {
272                    return false;
273                };
274                let Ok(commit) = store.get_commit(commit_id) else {
275                    return false;
276                };
277                // if parent is root commit then it's the only possible parent
278                let Some(Ok(parent)) = commit.parents().next() else {
279                    return false;
280                };
281
282                // root commit is direct parent of current commit => repo is effectively bare
283                // current commit should be empty
284                parent.change_id() == store.root_commit().change_id()
285                    && commit.is_empty(&*repo).unwrap_or_default()
286            }
287        }
288    }
289
290    pub fn add_worktree(&self, path: &Path) -> Result<Option<(String, PathBuf)>> {
291        match self {
292            RepoProvider::Git(_) => {
293                let Ok(head) = self.head_name() else {
294                    return Ok(None);
295                };
296                // Add the default branch as a tree (usually either main or master)
297                process::Command::new("git")
298                    .current_dir(path)
299                    .args(["worktree", "add", &head])
300                    .stderr(Stdio::inherit())
301                    .output()
302                    .change_context(TmsError::GitError)?;
303                Ok(Some((head.clone(), path.to_path_buf().join(&head))))
304            }
305            RepoProvider::Jujutsu(_) => {
306                process::Command::new("jj")
307                    .current_dir(path)
308                    .args(["workspace", "add", "-r", "trunk()", "trunk"])
309                    .stderr(Stdio::inherit())
310                    .output()
311                    .change_context(TmsError::GitError)?;
312                Ok(Some(("trunk".into(), path.to_path_buf().join("trunk"))))
313            }
314        }
315    }
316
317    pub fn worktrees(&'_ self) -> Result<Vec<Box<dyn Worktree + '_>>> {
318        match self {
319            RepoProvider::Git(repo) => Ok(repo
320                .worktrees()
321                .change_context(TmsError::GitError)?
322                .into_iter()
323                .map(|i| Box::new(i) as Box<dyn Worktree>)
324                .collect()),
325
326            RepoProvider::Jujutsu(workspace) => {
327                let repo = workspace
328                    .repo_loader()
329                    .load_at_head()
330                    .change_context(TmsError::GitError)?;
331                let workspace_store = SimpleWorkspaceStore::load(workspace.repo_path())
332                    .change_context(TmsError::GitError)?;
333                let workspaces = repo
334                    .view()
335                    .wc_commit_ids()
336                    .keys()
337                    .filter(|name| name.as_str() != workspace.workspace_name().as_str())
338                    .map(|name| workspace_store.get_workspace_path(name))
339                    .filter_map(|opt| opt.ok().flatten());
340
341                let repos = workspaces
342                    .filter_map(|path| {
343                        if let Ok(RepoProvider::Jujutsu(workspace)) =
344                            VcsProviders::Jujutsu.open(&path)
345                        {
346                            Some(Box::new(workspace) as Box<dyn Worktree>)
347                        } else {
348                            None
349                        }
350                    })
351                    .collect::<Vec<_>>();
352                Ok(repos)
353            }
354        }
355    }
356}
357
358pub fn find_repos(config: &Config) -> Result<HashMap<String, Vec<Session>>> {
359    let mut repos: HashMap<String, Vec<Session>> = HashMap::new();
360
361    search_dirs(config, |file, repo| {
362        if repo.is_worktree().unwrap_or(true) {
363            return Ok(());
364        }
365
366        let session_name = file
367            .path
368            .file_name()
369            .ok_or_else(|| {
370                Report::new(TmsError::GitError).attach_printable("Not a valid repository name")
371            })?
372            .to_string()?;
373
374        let session = Session::new(session_name, SessionType::Git(repo));
375        if let Some(list) = repos.get_mut(&session.name) {
376            list.push(session);
377        } else {
378            repos.insert(session.name.clone(), vec![session]);
379        }
380        Ok(())
381    })?;
382    Ok(repos)
383}
384
385fn search_dirs<F>(config: &Config, mut f: F) -> Result<()>
386where
387    F: FnMut(SearchDirectory, LazyRepoProvider) -> Result<()>,
388{
389    {
390        let directories = config.search_dirs().change_context(TmsError::ConfigError)?;
391        let mut to_search: VecDeque<SearchDirectory> = directories.into();
392        let vcs_provider_config = config
393            .vcs_providers
394            .clone()
395            .unwrap_or_else(|| DEFAULT_VCS_PROVIDERS.to_vec());
396
397        let excluder = if let Some(excluded_dirs) = &config.excluded_dirs {
398            Some(
399                AhoCorasickBuilder::new()
400                    .match_kind(MatchKind::LeftmostFirst)
401                    .build(excluded_dirs)
402                    .change_context(TmsError::IoError)?,
403            )
404        } else {
405            None
406        };
407
408        while let Some(file) = to_search.pop_front() {
409            if let Some(ref excluder) = excluder {
410                if excluder.is_match(&file.path.to_string()?) {
411                    continue;
412                }
413            }
414
415            if let Ok(repo) = LazyRepoProvider::new(&file.path, &vcs_provider_config) {
416                f(file, repo)?;
417            } else if file.path.is_dir() && file.depth > 0 {
418                match fs::read_dir(&file.path) {
419                    Err(ref e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
420                        eprintln!(
421                        "Warning: insufficient permissions to read '{0}'. Skipping directory...",
422                        file.path.to_string()?
423                    );
424                    }
425                    Err(e) => {
426                        let report = report!(e)
427                            .change_context(TmsError::IoError)
428                            .attach_printable(format!("Could not read directory {:?}", file.path));
429                        return Err(report);
430                    }
431                    Ok(read_dir) => {
432                        let mut subdirs = read_dir
433                            .filter_map(|dir_entry| {
434                                if let Ok(dir) = dir_entry {
435                                    Some(SearchDirectory::new(dir.path(), file.depth - 1))
436                                } else {
437                                    None
438                                }
439                            })
440                            .collect::<VecDeque<SearchDirectory>>();
441
442                        if !subdirs.is_empty() {
443                            to_search.append(&mut subdirs);
444                        }
445                    }
446                }
447            }
448        }
449        Ok(())
450    }
451}
452
453pub fn find_submodules<'a>(
454    submodules: impl Iterator<Item = Submodule<'a>>,
455    parent_name: &String,
456    repos: &mut impl SessionContainer,
457    config: &Config,
458) -> Result<()> {
459    for submodule in submodules {
460        let repo = match submodule.open() {
461            Ok(Some(repo)) => repo,
462            _ => continue,
463        };
464        let path = match repo.workdir() {
465            Some(path) => path.to_path_buf(),
466            _ => continue,
467        };
468        let submodule_file_name = path
469            .file_name()
470            .ok_or_else(|| {
471                Report::new(TmsError::GitError).attach_printable("Not a valid submodule name")
472            })?
473            .to_string()?;
474        let session_name = format!("{}>{}", parent_name, submodule_file_name);
475        let name = if let Some(true) = config.display_full_path {
476            path.display().to_string()
477        } else {
478            session_name.clone()
479        };
480
481        if config.recursive_submodules == Some(true) {
482            if let Ok(Some(submodules)) = repo.submodules() {
483                find_submodules(submodules, &name, repos, config)?;
484            }
485        }
486        let session = Session::new(
487            session_name,
488            SessionType::Git(LazyRepoProvider::new_resolved(
489                &path,
490                VcsProviders::Git,
491                RepoProvider::Git(Box::new(repo)),
492            )),
493        );
494        repos.insert_session(name, session);
495    }
496    Ok(())
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use std::{fs, process::Command};
503    use tempfile::tempdir;
504
505    #[test]
506    fn gitlink_to_dot_bare_is_not_filtered_as_worktree() {
507        let dir = tempdir().unwrap();
508        let root = dir.path();
509
510        // 1. Create the bare repository
511        Command::new("git")
512            .args(["init", "--bare"])
513            .arg(root.join(".bare"))
514            .status()
515            .unwrap();
516
517        // 2. Dynamically create the absolute path for the gitdir pointer
518        let bare_path = root.join(".bare");
519        let gitlink_content = format!("gitdir: {}\n", bare_path.display());
520
521        // 3. Write the absolute path to the .git file
522        fs::write(root.join(".git"), gitlink_content).unwrap();
523
524        // 4. Run the assertions
525        let repo = LazyRepoProvider::new(root, &[VcsProviders::Git]).unwrap();
526        assert!(!repo.is_worktree().unwrap());
527    }
528}