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/// Finds repositories recursively, returning their path
213pub fn find_repo_paths(path: &Path) -> Result<Vec<PathBuf>, Error> {
214    let mut repos = Vec::new();
215
216    let git_dir = path.join(".git");
217    let git_worktree = path.join(repo::GIT_MAIN_WORKTREE_DIRECTORY);
218
219    if git_dir.exists() || git_worktree.exists() {
220        repos.push(path.to_path_buf());
221    } else {
222        match fs::read_dir(path) {
223            Ok(contents) => {
224                for content in contents {
225                    match content {
226                        Ok(entry) => {
227                            let path = path::from_std_path_buf(entry.path())?;
228                            if path.is_symlink() {
229                                continue;
230                            }
231                            if path.is_dir() {
232                                match find_repo_paths(&path) {
233                                    Ok(ref mut r) => repos.append(r),
234                                    Err(error) => return Err(error),
235                                }
236                            }
237                        }
238                        Err(e) => {
239                            return Err(Error::DirectoryAccess {
240                                message: e.to_string(),
241                            });
242                        }
243                    }
244                }
245            }
246            Err(e) => {
247                return Err(match e.kind() {
248                    std::io::ErrorKind::NotFound => Error::NotFound {
249                        path: path.to_path_buf(),
250                    },
251                    kind => Error::Open {
252                        path: path.to_path_buf(),
253                        kind,
254                    },
255                });
256            }
257        }
258    }
259
260    Ok(repos)
261}
262
263fn sync_repo(
264    root_path: &Path,
265    repo: &repo::Repo,
266    init_worktree: bool,
267    result_channel: &mpsc::SyncSender<SyncTreesMessage>,
268) -> Result<(), Error> {
269    let repo_path = root_path.join(repo.fullname().as_str());
270    let actual_git_directory = get_actual_git_directory(&repo_path, repo.worktree_setup);
271
272    let mut newly_created = false;
273
274    // Syncing a repository can have a few different flows, depending on the
275    // repository that is to be cloned and the local directory:
276    //
277    // * If the local directory already exists, we have to make sure that it matches
278    //   the worktree configuration, as there is no way to convert. If the sync is
279    //   supposed to be worktree-aware, but the local directory is not, we abort.
280    //   Note that we could also automatically convert here. In any case, the other
281    //   direction (converting a worktree repository to non-worktree) cannot work,
282    //   as we'd have to throw away the worktrees.
283    //
284    // * If the local directory does not yet exist, we have to actually do something
285    //   ;). If no remote is specified, we just initialize a new repository (git
286    //   init) and are done.
287    //
288    //   If there are (potentially multiple) remotes configured, we have to clone.
289    // We assume   that the first remote is the canonical one that we do the
290    // first clone from. After   cloning, we just add the other remotes as usual
291    // (as if they were added to the config   afterwards)
292    //
293    // Branch handling:
294    //
295    // Handling the branches on checkout is a bit magic. For minimum surprises, we
296    // just set up local tracking branches for all remote branches.
297    if repo_path.exists() && repo_path.read_dir()?.next().is_some() {
298        if repo.worktree_setup.is_worktree() && !actual_git_directory.exists() {
299            return Err(Error::WorktreeExpected);
300        }
301    } else if let Some(first) = repo.remotes.first() {
302        send_msg(
303            result_channel,
304            SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::Cloning((
305                repo_path.clone(),
306                first.url.clone(),
307            )))),
308        );
309
310        match repo::clone_repo(first, &repo_path, repo.worktree_setup) {
311            Ok(()) => send_msg(
312                result_channel,
313                SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::Cloned(repo.name.clone()))),
314            ),
315            Err(e) => {
316                return Err(Error::CloneFailed {
317                    message: e.to_string(),
318                });
319            }
320        }
321
322        newly_created = true;
323    } else {
324        send_msg(
325            result_channel,
326            SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::Init(repo.name.clone()))),
327        );
328        match repo::RepoHandle::init(&repo_path, repo.worktree_setup) {
329            Ok(_repo_handle) => {
330                send_msg(
331                    result_channel,
332                    SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::Created(
333                        repo.name.clone(),
334                    ))),
335                );
336            }
337            Err(e) => {
338                return Err(Error::InitFailed {
339                    message: e.to_string(),
340                });
341            }
342        }
343    }
344
345    let repo_handle =
346        match repo::RepoHandle::open_with_worktree_setup(&repo_path, repo.worktree_setup) {
347            Ok(repo) => repo,
348            Err(error) => {
349                if !repo.worktree_setup.is_worktree()
350                    && repo::WorktreeRepoHandle::open(&repo_path).is_ok()
351                {
352                    return Err(Error::WorktreeNotExpected);
353                } else {
354                    return Err(error.into());
355                }
356            }
357        };
358
359    let repo_handle = if newly_created && repo.worktree_setup.is_worktree() && init_worktree {
360        let repo_handle = WorktreeRepoHandle::from_handle_unchecked(repo_handle);
361
362        match repo_handle.default_branch() {
363            Ok(branch) => {
364                repo_handle.add_worktree(
365                    &WorktreeName::new(branch.name()?.into_string())?,
366                    TrackingSelection::Automatic,
367                )?;
368            }
369            Err(_error) => send_msg(
370                result_channel,
371                SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::SkippingWorktreeInit(
372                    repo.name.clone(),
373                ))),
374            ),
375        }
376
377        repo_handle.into_handle()
378    } else {
379        repo_handle
380    };
381
382    let current_remotes = repo_handle.remotes()?;
383
384    for remote in &repo.remotes {
385        let current_remote = repo_handle.find_remote(&remote.name)?;
386
387        if let Some(current_remote) = current_remote {
388            let current_url = current_remote.url()?;
389
390            if remote.url != current_url {
391                send_msg(
392                    result_channel,
393                    SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::UpdatingRemote((
394                        repo.name.clone(),
395                        remote.name.clone(),
396                        remote.url.clone(),
397                    )))),
398                );
399                repo_handle.remote_set_url(&remote.name, &remote.url)?;
400            }
401        } else {
402            send_msg(
403                result_channel,
404                SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::CreateRemote((
405                    repo.name.clone(),
406                    remote.name.clone(),
407                    remote.url.clone(),
408                )))),
409            );
410            repo_handle.new_remote(&remote.name, &remote.url)?;
411        }
412    }
413
414    for current_remote in &current_remotes {
415        if !repo.remotes.iter().any(|r| &r.name == current_remote) {
416            send_msg(
417                result_channel,
418                SyncTreesMessage::SyncTreeMessage(Ok(SyncTreeMessage::DeleteRemote((
419                    repo.name.clone(),
420                    current_remote.clone(),
421                )))),
422            );
423            repo_handle.remote_delete(current_remote)?;
424        }
425    }
426
427    Ok(())
428}
429
430fn get_actual_git_directory(path: &Path, worktree_setup: WorktreeSetup) -> PathBuf {
431    if worktree_setup.is_worktree() {
432        path.join(repo::GIT_MAIN_WORKTREE_DIRECTORY)
433    } else {
434        path.to_path_buf()
435    }
436}