grm/
tree.rs

1//! A `Tree` represents a collection of `Repo` instances under a shared root
2//! directory.
3
4use std::{
5    fs,
6    path::{Path, PathBuf},
7};
8
9use thiserror::Error;
10
11use super::{
12    config,
13    output::{print_error, print_repo_action, print_repo_error, print_repo_success, print_warning},
14    path, repo,
15    worktree::{self, WorktreeName},
16};
17
18#[derive(Debug, Error)]
19pub enum Error {
20    #[error(transparent)]
21    Config(#[from] config::Error),
22    #[error(transparent)]
23    Repo(#[from] repo::Error),
24    #[error(transparent)]
25    Worktree(#[from] worktree::Error),
26    #[error("Failed to open \"{:?}\": Not found", .path)]
27    NotFound { path: PathBuf },
28    #[error("Failed to open \"{:?}\": {}", .path, .kind)]
29    Open {
30        path: PathBuf,
31        kind: std::io::ErrorKind,
32    },
33    #[error(transparent)]
34    Io(#[from] std::io::Error),
35    #[error("Error accessing directory: {}", .message)]
36    DirectoryAccess { message: String },
37    #[error("Repo already exists, but is not using a worktree setup")]
38    WorktreeExpected,
39    #[error("Repo already exists, but is using a worktree setup")]
40    WorktreeNotExpected,
41    #[error("Repository failed during init: {}", .message)]
42    InitFailed { message: String },
43    #[error("Repository failed during clone: {}", .message)]
44    CloneFailed { message: String },
45    #[error(transparent)]
46    Path(#[from] path::Error),
47}
48
49#[derive(Debug)]
50pub struct Root(PathBuf);
51
52impl Root {
53    pub fn new(s: PathBuf) -> Self {
54        Self(s)
55    }
56
57    pub fn as_path(&self) -> &Path {
58        &self.0
59    }
60
61    pub fn into_path_buf(self) -> PathBuf {
62        self.0
63    }
64}
65
66impl From<config::Root> for Root {
67    fn from(other: config::Root) -> Self {
68        Self::new(other.into_path_buf())
69    }
70}
71
72impl From<Root> for config::Root {
73    fn from(other: Root) -> Self {
74        Self::new(other.into_path_buf())
75    }
76}
77
78pub struct Tree {
79    pub root: Root,
80    pub repos: Vec<repo::Repo>,
81}
82
83impl From<config::Tree> for Tree {
84    fn from(other: config::Tree) -> Self {
85        Self {
86            root: other.root.into(),
87            repos: other
88                .repos
89                .map(|repos| repos.into_iter().map(Into::into).collect())
90                .unwrap_or_default(),
91        }
92    }
93}
94
95#[derive(PartialEq, Eq)]
96pub struct RepoPath(PathBuf);
97
98pub fn find_unmanaged_repos(
99    root_path: &Path,
100    managed_repos: &[repo::Repo],
101) -> Result<Vec<RepoPath>, Error> {
102    let mut unmanaged_repos = Vec::new();
103
104    for path in find_repo_paths(root_path)? {
105        if !managed_repos
106            .iter()
107            .any(|r| Path::new(root_path).join(r.fullname().as_str()) == path)
108        {
109            unmanaged_repos.push(RepoPath(path));
110        }
111    }
112    Ok(unmanaged_repos)
113}
114
115pub fn sync_trees(config: config::Config, init_worktree: bool) -> Result<bool, Error> {
116    let mut failures = false;
117
118    let mut unmanaged_repos_absolute_paths = vec![];
119    let mut managed_repos_absolute_paths = vec![];
120
121    let trees: Vec<Tree> = config.get_trees()?.into_iter().map(Into::into).collect();
122
123    for tree in trees {
124        let root_path = path::expand_path(Path::new(&tree.root.0))?;
125
126        for repo in &tree.repos {
127            managed_repos_absolute_paths.push(RepoPath(root_path.join(repo.fullname().as_str())));
128            match sync_repo(&root_path, repo, init_worktree) {
129                Ok(()) => print_repo_success(repo.name.as_str(), "OK"),
130                Err(error) => {
131                    print_repo_error(repo.name.as_str(), &error.to_string());
132                    failures = true;
133                }
134            }
135        }
136
137        match find_unmanaged_repos(&root_path, &tree.repos) {
138            Ok(repos) => {
139                for path in repos {
140                    if !unmanaged_repos_absolute_paths.contains(&path) {
141                        unmanaged_repos_absolute_paths.push(path);
142                    }
143                }
144            }
145            Err(error) => {
146                print_error(&format!("Error getting unmanaged repos: {error}"));
147                failures = true;
148            }
149        }
150    }
151
152    for unmanaged_repo_absolute_path in &unmanaged_repos_absolute_paths {
153        if managed_repos_absolute_paths
154            .iter()
155            .any(|managed_repo_absolute_path| {
156                managed_repo_absolute_path == unmanaged_repo_absolute_path
157            })
158        {
159            continue;
160        }
161        print_warning(format!(
162            "Found unmanaged repository: \"{}\"",
163            path::path_as_string(&unmanaged_repo_absolute_path.0)?
164        ));
165    }
166
167    Ok(!failures)
168}
169
170/// Finds repositories recursively, returning their path
171pub fn find_repo_paths(path: &Path) -> Result<Vec<PathBuf>, Error> {
172    let mut repos = Vec::new();
173
174    let git_dir = path.join(".git");
175    let git_worktree = path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY);
176
177    if git_dir.exists() || git_worktree.exists() {
178        repos.push(path.to_path_buf());
179    } else {
180        match fs::read_dir(path) {
181            Ok(contents) => {
182                for content in contents {
183                    match content {
184                        Ok(entry) => {
185                            let path = entry.path();
186                            if path.is_symlink() {
187                                continue;
188                            }
189                            if path.is_dir() {
190                                match find_repo_paths(&path) {
191                                    Ok(ref mut r) => repos.append(r),
192                                    Err(error) => return Err(error),
193                                }
194                            }
195                        }
196                        Err(e) => {
197                            return Err(Error::DirectoryAccess {
198                                message: e.to_string(),
199                            });
200                        }
201                    }
202                }
203            }
204            Err(e) => {
205                return Err(match e.kind() {
206                    std::io::ErrorKind::NotFound => Error::NotFound {
207                        path: path.to_path_buf(),
208                    },
209                    kind => Error::Open {
210                        path: path.to_path_buf(),
211                        kind,
212                    },
213                });
214            }
215        }
216    }
217
218    Ok(repos)
219}
220
221fn sync_repo(root_path: &Path, repo: &repo::Repo, init_worktree: bool) -> Result<(), Error> {
222    let repo_path = root_path.join(repo.fullname().as_str());
223    let actual_git_directory = get_actual_git_directory(&repo_path, repo.worktree_setup);
224
225    let mut newly_created = false;
226
227    // Syncing a repository can have a few different flows, depending on the
228    // repository that is to be cloned and the local directory:
229    //
230    // * If the local directory already exists, we have to make sure that it matches
231    //   the worktree configuration, as there is no way to convert. If the sync is
232    //   supposed to be worktree-aware, but the local directory is not, we abort.
233    //   Note that we could also automatically convert here. In any case, the other
234    //   direction (converting a worktree repository to non-worktree) cannot work,
235    //   as we'd have to throw away the worktrees.
236    //
237    // * If the local directory does not yet exist, we have to actually do something
238    //   ;). If no remote is specified, we just initialize a new repository (git
239    //   init) and are done.
240    //
241    //   If there are (potentially multiple) remotes configured, we have to clone.
242    // We assume   that the first remote is the canonical one that we do the
243    // first clone from. After   cloning, we just add the other remotes as usual
244    // (as if they were added to the config   afterwards)
245    //
246    // Branch handling:
247    //
248    // Handling the branches on checkout is a bit magic. For minimum surprises, we
249    // just set up local tracking branches for all remote branches.
250    if repo_path.exists() && repo_path.read_dir()?.next().is_some() {
251        if repo.worktree_setup && !actual_git_directory.exists() {
252            return Err(Error::WorktreeExpected);
253        }
254    } else if let Some(first) = repo.remotes.first() {
255        match repo::clone_repo(first, &repo_path, repo.worktree_setup) {
256            Ok(()) => {
257                print_repo_success(repo.name.as_str(), "Repository successfully cloned");
258            }
259            Err(e) => {
260                return Err(Error::CloneFailed {
261                    message: e.to_string(),
262                });
263            }
264        }
265
266        newly_created = true;
267    } else {
268        print_repo_action(
269            repo.name.as_str(),
270            "Repository does not have remotes configured, initializing new",
271        );
272        match repo::RepoHandle::init(&repo_path, repo.worktree_setup) {
273            Ok(_repo_handle) => {
274                print_repo_success(repo.name.as_str(), "Repository created");
275            }
276            Err(e) => {
277                return Err(Error::InitFailed {
278                    message: e.to_string(),
279                });
280            }
281        }
282    }
283
284    let repo_handle = match repo::RepoHandle::open(&repo_path, repo.worktree_setup) {
285        Ok(repo) => repo,
286        Err(error) => {
287            if !repo.worktree_setup && repo::RepoHandle::open(&repo_path, true).is_ok() {
288                return Err(Error::WorktreeNotExpected);
289            } else {
290                return Err(error.into());
291            }
292        }
293    };
294
295    if newly_created && repo.worktree_setup && init_worktree {
296        match repo_handle.default_branch() {
297            Ok(branch) => {
298                worktree::add_worktree(
299                    &repo_path,
300                    &WorktreeName::new(branch.name()?.into_string()),
301                    None,
302                    false,
303                )?;
304            }
305            Err(_error) => print_repo_error(
306                repo.name.as_str(),
307                "Could not determine default branch, skipping worktree initializtion",
308            ),
309        }
310    }
311
312    let current_remotes = repo_handle.remotes()?;
313
314    for remote in &repo.remotes {
315        let current_remote = repo_handle.find_remote(&remote.name)?;
316
317        if let Some(current_remote) = current_remote {
318            let current_url = current_remote.url()?;
319
320            if remote.url != current_url {
321                print_repo_action(
322                    repo.name.as_str(),
323                    &format!("Updating remote {} to \"{}\"", &remote.name, &remote.url),
324                );
325                repo_handle.remote_set_url(&remote.name, &remote.url)?;
326            }
327        } else {
328            print_repo_action(
329                repo.name.as_str(),
330                &format!(
331                    "Setting up new remote \"{}\" to \"{}\"",
332                    &remote.name, &remote.url
333                ),
334            );
335            repo_handle.new_remote(&remote.name, &remote.url)?;
336        }
337    }
338
339    for current_remote in &current_remotes {
340        if !repo.remotes.iter().any(|r| &r.name == current_remote) {
341            print_repo_action(
342                repo.name.as_str(),
343                &format!("Deleting remote \"{}\"", &current_remote),
344            );
345            repo_handle.remote_delete(current_remote)?;
346        }
347    }
348
349    Ok(())
350}
351
352fn get_actual_git_directory(path: &Path, is_worktree: bool) -> PathBuf {
353    if is_worktree {
354        path.join(worktree::GIT_MAIN_WORKTREE_DIRECTORY)
355    } else {
356        path.to_path_buf()
357    }
358}