grm/
table.rs

1use std::{
2    fmt::{self, Write},
3    path::{Path, PathBuf},
4};
5
6use comfy_table::{Cell, Table};
7use thiserror::Error;
8
9use super::{config, path, repo, repo::ProjectName, tree, worktree::WorktreeName};
10
11#[derive(Debug, Error)]
12pub enum Error {
13    #[error(transparent)]
14    Config(#[from] config::Error),
15    #[error("repo error: {0}")]
16    Repo(#[from] repo::Error),
17    #[error("Directory is not a git directory")]
18    NotAGitDirectory,
19    #[error("Worktree {:?} does not have a directory", .worktree)]
20    WorktreeWithoutDirectory { worktree: WorktreeName },
21    #[error(transparent)]
22    Path(#[from] path::Error),
23    #[error(transparent)]
24    Fmt(#[from] fmt::Error),
25    #[error("Found {:?}, which is not a valid worktree directory!", .path)]
26    InvalidWorktreeDirectory { path: PathBuf },
27    #[error("{}: Repository does not exist. Run sync?", .name)]
28    RepoDoesNotExist { name: ProjectName },
29    #[error("{}: No git repository found. Run sync?", .name)]
30    RepoNotGit { name: ProjectName },
31    #[error("{}: Opening repository failed: {}", .name, .message)]
32    RepoOpenFailed { name: ProjectName, message: String },
33    #[error("{}: Couldn't add repo status: {}", .name, .message)]
34    RepoStatusFailed { name: ProjectName, message: String },
35}
36
37fn add_table_header(table: &mut Table) {
38    table
39        .load_preset(comfy_table::presets::UTF8_FULL)
40        .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS)
41        .set_header([
42            Cell::new("Repo"),
43            Cell::new("Worktree"),
44            Cell::new("Status"),
45            Cell::new("Branches"),
46            Cell::new("HEAD"),
47            Cell::new("Remotes"),
48        ]);
49}
50
51fn add_repo_status(
52    table: &mut Table,
53    repo_name: Option<&ProjectName>,
54    repo_handle: &repo::RepoHandle,
55    is_worktree: bool,
56) -> Result<(), Error> {
57    let repo_status = repo_handle.status(is_worktree).map_err(Error::Repo)?;
58
59    let branch_info = {
60        let mut acc = String::new();
61        for (branch_name, remote_branch) in repo_status.branches {
62            writeln!(
63                &mut acc,
64                "branch: {}{}",
65                &branch_name,
66                &match remote_branch {
67                    None => String::from(" <!local>"),
68                    Some((remote_branch_name, remote_tracking_status)) => {
69                        format!(
70                            " <{}>{}",
71                            remote_branch_name,
72                            &match remote_tracking_status {
73                                repo::RemoteTrackingStatus::UpToDate => String::from(" \u{2714}"),
74                                repo::RemoteTrackingStatus::Ahead(d) => format!(" [+{}]", &d),
75                                repo::RemoteTrackingStatus::Behind(d) => format!(" [-{}]", &d),
76                                repo::RemoteTrackingStatus::Diverged(d1, d2) =>
77                                    format!(" [+{}/-{}]", &d1, &d2),
78                            }
79                        )
80                    }
81                }
82            )?;
83        }
84        acc.trim().to_owned()
85    };
86
87    let remote_status = {
88        let mut acc = String::new();
89        for remote in repo_status.remotes {
90            writeln!(&mut acc, "{remote}")?;
91        }
92
93        acc.trim().to_owned()
94    };
95
96    table.add_row([
97        match repo_name {
98            Some(name) => name.as_str(),
99            None => "unknown",
100        },
101        if is_worktree { "\u{2714}" } else { "" },
102        &if is_worktree {
103            String::new()
104        } else {
105            match repo_status.changes {
106                Some(changes) => {
107                    let mut out = Vec::new();
108                    if changes.files_new > 0 {
109                        out.push(format!("New: {}\n", changes.files_new));
110                    }
111                    if changes.files_modified > 0 {
112                        out.push(format!("Modified: {}\n", changes.files_modified));
113                    }
114                    if changes.files_deleted > 0 {
115                        out.push(format!("Deleted: {}\n", changes.files_deleted));
116                    }
117                    out.into_iter().collect::<String>().trim().to_owned()
118                }
119                None => String::from("\u{2714}"),
120            }
121        },
122        &branch_info,
123        &if is_worktree {
124            String::new()
125        } else {
126            match repo_status.head {
127                Some(head) => head.into_string(),
128                None => String::from("Empty"),
129            }
130        },
131        &remote_status,
132    ]);
133
134    Ok(())
135}
136
137// Don't return table, return a type that implements Display(?)
138pub fn get_worktree_status_table(
139    repo: &repo::RepoHandle,
140    directory: &Path,
141) -> Result<(impl std::fmt::Display, Vec<Error>), Error> {
142    let worktrees = repo.get_worktrees().map_err(Error::Repo)?;
143    let mut table = Table::new();
144
145    let mut errors = Vec::new();
146
147    add_worktree_table_header(&mut table);
148    for worktree in &worktrees {
149        let worktree_dir = &directory.join(worktree.name().as_str());
150        if worktree_dir.exists() {
151            let repo = match repo::RepoHandle::open(worktree_dir, false) {
152                Ok(repo) => repo,
153                Err(error) => {
154                    errors.push(error.into());
155                    continue;
156                }
157            };
158            if let Err(error) = add_worktree_status(&mut table, worktree, &repo) {
159                errors.push(error);
160            }
161        } else {
162            errors.push(Error::WorktreeWithoutDirectory {
163                worktree: worktree.name().clone(),
164            });
165        }
166    }
167    for worktree in
168        repo::RepoHandle::find_unmanaged_worktrees(repo, directory).map_err(Error::Repo)?
169    {
170        errors.push(Error::InvalidWorktreeDirectory { path: worktree });
171    }
172    Ok((table, errors))
173}
174
175pub fn get_status_table(config: config::Config) -> Result<(Vec<Table>, Vec<Error>), Error> {
176    let mut errors = Vec::new();
177    let mut tables = Vec::new();
178
179    let trees: Vec<tree::Tree> = config.get_trees()?.into_iter().map(Into::into).collect();
180
181    for tree in trees {
182        let repos = tree.repos;
183
184        let root_path = path::expand_path(tree.root.as_path())?;
185
186        let mut table = Table::new();
187        add_table_header(&mut table);
188
189        for repo in &repos {
190            let repo_path = root_path.join(repo.name.as_str());
191
192            if !repo_path.exists() {
193                errors.push(Error::RepoDoesNotExist {
194                    name: repo.name.clone(),
195                });
196                continue;
197            }
198
199            let repo_handle = repo::RepoHandle::open(&repo_path, repo.worktree_setup);
200
201            let repo_handle = match repo_handle {
202                Ok(repo) => repo,
203                Err(error) => {
204                    if matches!(error, repo::Error::NotFound) {
205                        errors.push(Error::RepoNotGit {
206                            name: repo.name.clone(),
207                        });
208                    } else {
209                        errors.push(Error::RepoOpenFailed {
210                            name: repo.name.clone(),
211                            message: error.to_string(),
212                        });
213                    }
214                    continue;
215                }
216            };
217
218            if let Err(err) = add_repo_status(
219                &mut table,
220                Some(&repo.name),
221                &repo_handle,
222                repo.worktree_setup,
223            ) {
224                errors.push(Error::RepoStatusFailed {
225                    name: repo.name.clone(),
226                    message: err.to_string(),
227                });
228            }
229        }
230
231        tables.push(table);
232    }
233
234    Ok((tables, errors))
235}
236
237fn add_worktree_table_header(table: &mut Table) {
238    table
239        .load_preset(comfy_table::presets::UTF8_FULL)
240        .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS)
241        .set_header([
242            Cell::new("Worktree"),
243            Cell::new("Status"),
244            Cell::new("Branch"),
245            Cell::new("Remote branch"),
246        ]);
247}
248
249fn add_worktree_status(
250    table: &mut Table,
251    worktree: &repo::Worktree,
252    repo: &repo::RepoHandle,
253) -> Result<(), Error> {
254    let repo_status = repo.status(false).map_err(Error::Repo)?;
255
256    let local_branch = repo.head_branch().map_err(Error::Repo)?;
257
258    let upstream_output = match local_branch.upstream() {
259        Ok(remote_branch) => {
260            let remote_branch_name = remote_branch.name().map_err(Error::Repo)?;
261
262            let (ahead, behind) = repo
263                .graph_ahead_behind(&local_branch, &remote_branch)
264                .map_err(Error::Repo)?;
265
266            format!(
267                "{}{}\n",
268                &remote_branch_name,
269                &match (ahead, behind) {
270                    (0, 0) => String::new(),
271                    (d, 0) => format!(" [+{}]", &d),
272                    (0, d) => format!(" [-{}]", &d),
273                    (d1, d2) => format!(" [+{}/-{}]", &d1, &d2),
274                },
275            )
276        }
277        Err(_) => String::new(),
278    };
279
280    table.add_row([
281        worktree.name().as_str(),
282        &match repo_status.changes {
283            Some(changes) => {
284                let mut out = Vec::new();
285                if changes.files_new > 0 {
286                    out.push(format!("New: {}\n", changes.files_new));
287                }
288                if changes.files_modified > 0 {
289                    out.push(format!("Modified: {}\n", changes.files_modified));
290                }
291                if changes.files_deleted > 0 {
292                    out.push(format!("Deleted: {}\n", changes.files_deleted));
293                }
294                out.into_iter().collect::<String>().trim().to_owned()
295            }
296            None => String::from("\u{2714}"),
297        },
298        local_branch.name().map_err(Error::Repo)?.as_str(),
299        &upstream_output,
300    ]);
301
302    Ok(())
303}
304
305pub fn show_single_repo_status(
306    path: &Path,
307) -> Result<(impl std::fmt::Display, Vec<String>), Error> {
308    let mut table = Table::new();
309    let mut warnings = Vec::new();
310
311    let is_worktree = repo::RepoHandle::detect_worktree(path);
312    add_table_header(&mut table);
313
314    let repo_handle = repo::RepoHandle::open(path, is_worktree);
315
316    if let Err(error) = repo_handle {
317        if matches!(error, repo::Error::NotFound) {
318            return Err(Error::NotAGitDirectory);
319        } else {
320            return Err(error.into());
321        }
322    }
323
324    let repo_name = match path.file_name() {
325        None => {
326            warnings.push(format!(
327                "Cannot detect repo name for path {}. Are you working in /?",
328                &path.display()
329            ));
330            None
331        }
332        Some(file_name) => match file_name.to_str() {
333            None => {
334                warnings.push(format!(
335                    "Name of repo directory {} is not valid UTF-8",
336                    &path.display()
337                ));
338                None
339            }
340            Some(name) => Some(ProjectName::new(name.to_owned())),
341        },
342    };
343
344    add_repo_status(&mut table, repo_name.as_ref(), &repo_handle?, is_worktree)?;
345
346    Ok((table, warnings))
347}