Skip to main content

grm/
tree.rs

1//! A `Tree` represents a collection of `Repo` instances under a shared root
2//! directory.
3
4use std::{fmt, fs, sync::mpsc};
5
6use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
7use thiserror::Error;
8
9use super::{
10    RemoteName, RemoteUrl, SyncTreesMessage, config, path,
11    repo::{self, RepoName, TrackingSelection, WorktreeName, WorktreeRepoHandle, WorktreeSetup},
12    send_msg,
13};
14
15#[derive(Debug, Error)]
16pub enum Error {
17    #[error(transparent)]
18    Config(#[from] config::Error),
19    #[error(transparent)]
20    Repo(#[from] repo::Error),
21    #[error(transparent)]
22    Worktree(#[from] repo::WorktreeError),
23    #[error("Failed to open \"{path}\": Not found")]
24    NotFound { path: PathBuf },
25    #[error("Failed to open \"{path}\": {kind}")]
26    Open {
27        path: PathBuf,
28        kind: std::io::ErrorKind,
29    },
30    #[error(transparent)]
31    Io(#[from] std::io::Error),
32    #[error("Error accessing directory: {message}")]
33    DirectoryAccess { message: String },
34    #[error("Repo already exists, but is not using a worktree setup")]
35    WorktreeExpected,
36    #[error("Repo already exists, but is using a worktree setup")]
37    WorktreeNotExpected,
38    #[error("Repository failed during init: {message}")]
39    InitFailed { message: String },
40    #[error("Repository failed during clone: {message}")]
41    CloneFailed { message: String },
42    #[error("Could not get trees from config: {message}")]
43    TreesFromConfig { message: String },
44    #[error(transparent)]
45    Path(#[from] path::Error),
46    #[error(transparent)]
47    WorktreeValidation(#[from] repo::WorktreeValidationError),
48}
49
50#[derive(Debug)]
51pub struct Root(PathBuf);
52
53impl Root {
54    pub fn new(s: PathBuf) -> Self {
55        Self(s)
56    }
57
58    pub fn as_path(&self) -> &Path {
59        &self.0
60    }
61
62    pub fn into_path_buf(self) -> PathBuf {
63        self.0
64    }
65}
66
67impl From<config::Root> for Root {
68    fn from(other: config::Root) -> Self {
69        Self::new(other.into_path_buf())
70    }
71}
72
73impl From<Root> for config::Root {
74    fn from(other: Root) -> Self {
75        Self::new(other.into_path_buf())
76    }
77}
78
79pub struct Tree {
80    pub root: Root,
81    pub repos: Vec<repo::Repo>,
82}
83
84impl From<config::Tree> for Tree {
85    fn from(other: config::Tree) -> Self {
86        Self {
87            root: other.root.into(),
88            repos: other
89                .repos
90                .map(|repos| repos.into_iter().map(Into::into).collect())
91                .unwrap_or_default(),
92        }
93    }
94}
95
96#[derive(PartialEq, Eq, Debug)]
97pub struct RepoPath(PathBuf);
98
99impl RepoPath {
100    pub fn as_path(&self) -> &Path {
101        self.0.as_path()
102    }
103}
104
105impl fmt::Display for RepoPath {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        write!(f, "{}", self.0)
108    }
109}
110
111pub fn find_unmanaged_repos(
112    root_path: &Path,
113    managed_repos: &[repo::Repo],
114) -> Result<Vec<RepoPath>, Error> {
115    let mut unmanaged_repos = Vec::new();
116
117    for path in find_repo_paths(root_path)? {
118        if !managed_repos
119            .iter()
120            .any(|r| Path::new(root_path).join(r.fullname().as_str()) == path)
121        {
122            unmanaged_repos.push(RepoPath(path));
123        }
124    }
125    Ok(unmanaged_repos)
126}
127
128#[derive(PartialEq, Eq, Copy, Clone)]
129pub enum OperationResult {
130    Success,
131    Failure,
132}
133
134impl OperationResult {
135    pub fn is_success(self) -> bool {
136        self == Self::Success
137    }
138
139    pub fn is_failure(self) -> bool {
140        !self.is_success()
141    }
142}
143
144pub enum SyncTreeMessage {
145    Cloning((PathBuf, RemoteUrl)),
146    Cloned(RepoName),
147    Init(RepoName),
148    Created(RepoName),
149    SyncDone(RepoName),
150    SkippingWorktreeInit(RepoName),
151    UpdatingRemote((RepoName, RemoteName, RemoteUrl)),
152    CreateRemote((RepoName, RemoteName, RemoteUrl)),
153    DeleteRemote((RepoName, RemoteName)),
154}
155
156pub fn sync_trees(
157    trees: Vec<Tree>,
158    init_worktree: bool,
159    result_channel: &mpsc::SyncSender<SyncTreesMessage>,
160) -> Result<(OperationResult, Vec<RepoPath>), Error> {
161    let mut failures = false;
162
163    let mut unmanaged_repos = vec![];
164    let mut managed_repos = vec![];
165
166    for tree in trees {
167        let root_path = path::expand_path(Path::new(&tree.root.0))?;
168
169        for repo in &tree.repos {
170            managed_repos.push(RepoPath(root_path.join(repo.fullname().as_str())));
171            match sync_repo(&root_path, repo, init_worktree, result_channel) {
172                Ok(()) => {
173                    send_msg(
174                        result_channel,
175                        SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::SyncDone(
176                            repo.name.clone(),
177                        ))),
178                    );
179                }
180                Err(error) => {
181                    send_msg(
182                        result_channel,
183                        SyncTreesMessage::SyncTreeMessage(Err((repo.name.clone(), error.into()))),
184                    );
185                    failures = true;
186                }
187            }
188        }
189
190        unmanaged_repos.extend(find_unmanaged_repos(&root_path, &tree.repos)?);
191    }
192
193    // It's possible that trees are nested or share a root, which means that a
194    // repo that is managed by one tree is detected as unmanaged in another tree.
195    // So we need to remove all unmanaged trees that are part of *any* tree.
196    unmanaged_repos.retain(|unmanaged_path| {
197        !managed_repos
198            .iter()
199            .any(|managed_path| unmanaged_path == managed_path)
200    });
201
202    Ok((
203        if failures {
204            OperationResult::Failure
205        } else {
206            OperationResult::Success
207        },
208        unmanaged_repos,
209    ))
210}
211
212/// Whether the given directory is the root of a repository, either a normal one
213/// or one in a worktree setup.
214fn is_repo_root(path: &Path) -> bool {
215    path.join(".git").exists() || path.join(repo::GIT_MAIN_WORKTREE_DIRECTORY).exists()
216}
217
218/// Finds repositories recursively, returning their path
219pub fn find_repo_paths(path: &Path) -> Result<Vec<PathBuf>, Error> {
220    let mut repos = Vec::new();
221
222    if is_repo_root(path) {
223        repos.push(path.to_path_buf());
224    } else {
225        match fs::read_dir(path) {
226            Ok(contents) => {
227                for content in contents {
228                    match content {
229                        Ok(entry) => {
230                            let path = path::from_std_path_buf(entry.path())?;
231                            if path.is_symlink() {
232                                continue;
233                            }
234                            if path.is_dir() {
235                                {
236                                    let r = &mut find_repo_paths(&path)?;
237                                    repos.append(r);
238                                }
239                            }
240                        }
241                        Err(e) => {
242                            return Err(Error::DirectoryAccess {
243                                message: e.to_string(),
244                            });
245                        }
246                    }
247                }
248            }
249            Err(e) => {
250                return Err(match e.kind() {
251                    std::io::ErrorKind::NotFound => Error::NotFound {
252                        path: path.to_path_buf(),
253                    },
254                    kind => Error::Open {
255                        path: path.to_path_buf(),
256                        kind,
257                    },
258                });
259            }
260        }
261    }
262
263    Ok(repos)
264}
265
266/// Collect everything under `path` that is not part of any repository, assuming
267/// that the contents of `path` have to be listed one by one.
268///
269/// Returns whether there is a repository somewhere below `path`, together with
270/// the paths that are not part of a repository. A directory that does not
271/// contain any repository is reported as a whole instead of each of its
272/// contents separately, as reporting a single directory is a lot more useful
273/// than reporting each file inside of it.
274///
275/// Symlinks are never followed. They are reported like normal files, as
276/// following them could lead outside of the tree.
277fn find_unmanaged_files_in(path: &Path) -> Result<(bool, Vec<PathBuf>), Error> {
278    if is_repo_root(path) {
279        return Ok((true, Vec::new()));
280    }
281
282    let contents = match fs::read_dir(path) {
283        Ok(contents) => contents,
284        Err(e) => {
285            return Err(match e.kind() {
286                std::io::ErrorKind::NotFound => Error::NotFound {
287                    path: path.to_path_buf(),
288                },
289                kind => Error::Open {
290                    path: path.to_path_buf(),
291                    kind,
292                },
293            });
294        }
295    };
296
297    let mut contains_repo = false;
298    let mut unmanaged = Vec::new();
299
300    for content in contents {
301        let entry = content.map_err(|e| Error::DirectoryAccess {
302            message: e.to_string(),
303        })?;
304        let entry_path = path::from_std_path_buf(entry.path())?;
305
306        if entry_path.is_dir() && !entry_path.is_symlink() {
307            let (sub_contains_repo, sub_unmanaged) = find_unmanaged_files_in(&entry_path)?;
308            if sub_contains_repo {
309                contains_repo = true;
310                unmanaged.extend(sub_unmanaged);
311            } else {
312                unmanaged.push(entry_path);
313            }
314        } else {
315            unmanaged.push(entry_path);
316        }
317    }
318
319    Ok((contains_repo, unmanaged))
320}
321
322/// Find all paths under `root_path` that are not part of any repository.
323///
324/// If `root_path` itself is a repository, nothing is reported. If it does not
325/// contain any repository at all, its contents are reported, as reporting the
326/// root itself would not tell the user anything they did not already know.
327pub fn find_unmanaged_files(root_path: &Path) -> Result<Vec<PathBuf>, Error> {
328    let (_contains_repo, mut unmanaged) = find_unmanaged_files_in(root_path)?;
329
330    unmanaged.sort();
331
332    Ok(unmanaged)
333}
334
335fn sync_repo(
336    root_path: &Path,
337    repo: &repo::Repo,
338    init_worktree: bool,
339    result_channel: &mpsc::SyncSender<SyncTreesMessage>,
340) -> Result<(), Error> {
341    let repo_path = root_path.join(repo.fullname().as_str());
342    let actual_git_directory = get_actual_git_directory(&repo_path, repo.worktree_setup);
343
344    let mut newly_created = false;
345
346    // Syncing a repository can have a few different flows, depending on the
347    // repository that is to be cloned and the local directory:
348    //
349    // * If the local directory already exists, we have to make sure that it matches
350    //   the worktree configuration, as there is no way to convert. If the sync is
351    //   supposed to be worktree-aware, but the local directory is not, we abort.
352    //   Note that we could also automatically convert here. In any case, the other
353    //   direction (converting a worktree repository to non-worktree) cannot work,
354    //   as we'd have to throw away the worktrees.
355    //
356    // * If the local directory does not yet exist, we have to actually do something
357    //   ;). If no remote is specified, we just initialize a new repository (git
358    //   init) and are done.
359    //
360    //   If there are (potentially multiple) remotes configured, we have to clone.
361    // We assume   that the first remote is the canonical one that we do the
362    // first clone from. After   cloning, we just add the other remotes as usual
363    // (as if they were added to the config   afterwards)
364    //
365    // Branch handling:
366    //
367    // Handling the branches on checkout is a bit magic. For minimum surprises, we
368    // just set up local tracking branches for all remote branches.
369    if repo_path.exists() && repo_path.read_dir()?.next().is_some() {
370        if repo.worktree_setup.is_worktree() && !actual_git_directory.exists() {
371            return Err(Error::WorktreeExpected);
372        }
373    } else if let Some(first) = repo.remotes.first() {
374        send_msg(
375            result_channel,
376            SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::Cloning((
377                repo_path.clone(),
378                first.url.clone(),
379            )))),
380        );
381
382        match repo::clone_repo(first, &repo_path, repo.worktree_setup) {
383            Ok(()) => send_msg(
384                result_channel,
385                SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::Cloned(repo.name.clone()))),
386            ),
387            Err(e) => {
388                return Err(Error::CloneFailed {
389                    message: e.to_string(),
390                });
391            }
392        }
393
394        newly_created = true;
395    } else {
396        send_msg(
397            result_channel,
398            SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::Init(repo.name.clone()))),
399        );
400        match repo::RepoHandle::init(&repo_path, repo.worktree_setup) {
401            Ok(_repo_handle) => {
402                send_msg(
403                    result_channel,
404                    SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::Created(
405                        repo.name.clone(),
406                    ))),
407                );
408            }
409            Err(e) => {
410                return Err(Error::InitFailed {
411                    message: e.to_string(),
412                });
413            }
414        }
415    }
416
417    let repo_handle =
418        match repo::RepoHandle::open_with_worktree_setup(&repo_path, repo.worktree_setup) {
419            Ok(repo) => repo,
420            Err(error) => {
421                if !repo.worktree_setup.is_worktree()
422                    && repo::WorktreeRepoHandle::open(&repo_path).is_ok()
423                {
424                    return Err(Error::WorktreeNotExpected);
425                } else {
426                    return Err(error.into());
427                }
428            }
429        };
430
431    let repo_handle = if newly_created && repo.worktree_setup.is_worktree() && init_worktree {
432        let repo_handle = WorktreeRepoHandle::from_handle_unchecked(repo_handle);
433
434        match repo_handle.default_branch() {
435            Ok(branch) => {
436                repo_handle.add_worktree(
437                    &WorktreeName::new(branch.name()?.into_string())?,
438                    TrackingSelection::Automatic,
439                )?;
440            }
441            Err(_error) => send_msg(
442                result_channel,
443                SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::SkippingWorktreeInit(
444                    repo.name.clone(),
445                ))),
446            ),
447        }
448
449        repo_handle.into_handle()
450    } else {
451        repo_handle
452    };
453
454    let current_remotes = repo_handle.remotes()?;
455
456    for remote in &repo.remotes {
457        let current_remote = repo_handle.find_remote(&remote.name)?;
458
459        if let Some(current_remote) = current_remote {
460            let current_url = current_remote.url()?;
461
462            if remote.url != current_url {
463                send_msg(
464                    result_channel,
465                    SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::UpdatingRemote((
466                        repo.name.clone(),
467                        remote.name.clone(),
468                        remote.url.clone(),
469                    )))),
470                );
471                repo_handle.remote_set_url(&remote.name, &remote.url)?;
472            }
473        } else {
474            send_msg(
475                result_channel,
476                SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::CreateRemote((
477                    repo.name.clone(),
478                    remote.name.clone(),
479                    remote.url.clone(),
480                )))),
481            );
482            repo_handle.new_remote(&remote.name, &remote.url)?;
483        }
484    }
485
486    for current_remote in &current_remotes {
487        if !repo.remotes.iter().any(|r| &r.name == current_remote) {
488            send_msg(
489                result_channel,
490                SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::DeleteRemote((
491                    repo.name.clone(),
492                    current_remote.clone(),
493                )))),
494            );
495            repo_handle.remote_delete(current_remote)?;
496        }
497    }
498
499    Ok(())
500}
501
502fn get_actual_git_directory(path: &Path, worktree_setup: WorktreeSetup) -> PathBuf {
503    if worktree_setup.is_worktree() {
504        path.join(repo::GIT_MAIN_WORKTREE_DIRECTORY)
505    } else {
506        path.to_path_buf()
507    }
508}