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 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 let Some(Ok(parent)) = commit.parents().next() else {
279 return false;
280 };
281
282 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 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 Command::new("git")
512 .args(["init", "--bare"])
513 .arg(root.join(".bare"))
514 .status()
515 .unwrap();
516
517 let bare_path = root.join(".bare");
519 let gitlink_content = format!("gitdir: {}\n", bare_path.display());
520
521 fs::write(root.join(".git"), gitlink_content).unwrap();
523
524 let repo = LazyRepoProvider::new(root, &[VcsProviders::Git]).unwrap();
526 assert!(!repo.is_worktree().unwrap());
527 }
528}