Skip to main content

grm/
table.rs

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