Skip to main content

git_wok/cmd/
status.rs

1use anyhow::*;
2use std::io::Write;
3
4use crate::{config, repo};
5
6#[derive(Debug)]
7struct RepoStatusRow {
8    name: String,
9    branch: String,
10    status: RepoLineStatus,
11}
12
13#[derive(Debug)]
14enum RepoLineStatus {
15    Clean,
16    NeedsLocking,
17    NewCommits,
18    HasUncommittedChanges,
19}
20
21pub fn status<W: Write>(
22    wok_config: &mut config::Config,
23    umbrella: &repo::Repo,
24    stdout: &mut W,
25    fetch: bool,
26) -> Result<()> {
27    // Fetch from remotes if requested
28    if fetch {
29        umbrella.fetch()?;
30        for config_repo in &wok_config.repos {
31            if let Some(subrepo) = umbrella.get_subrepo_by_path(&config_repo.path) {
32                subrepo.fetch()?;
33            }
34        }
35    }
36
37    let mut status_rows: Vec<RepoStatusRow> =
38        Vec::with_capacity(wok_config.repos.len() + 1);
39    let mut umbrella_status =
40        classify_umbrella_status(&umbrella.git_repo, &wok_config.repos)?;
41    if matches!(umbrella_status, RepoLineStatus::Clean)
42        && let Some(remote_comparison) =
43            umbrella.get_remote_comparison(&umbrella.head)?
44    {
45        let has_local_new_commits = match remote_comparison {
46            repo::RemoteComparison::Ahead(_) => true,
47            repo::RemoteComparison::Diverged(ahead, _) => ahead > 0,
48            repo::RemoteComparison::UpToDate
49            | repo::RemoteComparison::Behind(_)
50            | repo::RemoteComparison::NoRemote => false,
51        };
52        if has_local_new_commits {
53            umbrella_status = RepoLineStatus::NewCommits;
54        }
55    }
56    status_rows.push(RepoStatusRow {
57        name: "umbrella".to_string(),
58        branch: umbrella.head.clone(),
59        status: umbrella_status,
60    });
61
62    for config_repo in &wok_config.repos {
63        if let Some(subrepo) = umbrella.get_subrepo_by_path(&config_repo.path) {
64            let is_clean = is_repo_clean(&subrepo.git_repo, None)?;
65            let has_new_commits =
66                has_new_commits_vs_pointer(umbrella, &config_repo.path, subrepo)?;
67            status_rows.push(RepoStatusRow {
68                name: config_repo.path.display().to_string(),
69                branch: subrepo.head.clone(),
70                status: line_status_for_repo(is_clean, has_new_commits),
71            });
72        }
73    }
74
75    for row in &status_rows {
76        render_status_row(stdout, row)?;
77    }
78
79    Ok(())
80}
81
82fn line_status_for_repo(is_clean: bool, has_new_commits: bool) -> RepoLineStatus {
83    if !is_clean {
84        RepoLineStatus::HasUncommittedChanges
85    } else if has_new_commits {
86        RepoLineStatus::NewCommits
87    } else {
88        RepoLineStatus::Clean
89    }
90}
91
92fn render_status_row<W: Write>(stdout: &mut W, row: &RepoStatusRow) -> Result<()> {
93    let (symbol, label) = match row.status {
94        RepoLineStatus::Clean => ("✅", "clean"),
95        RepoLineStatus::NeedsLocking => ("🔒", "needs locking"),
96        RepoLineStatus::NewCommits => ("⬆", "new commits"),
97        RepoLineStatus::HasUncommittedChanges => ("❌", "has uncommitted changes"),
98    };
99    writeln!(
100        stdout,
101        "{} {} [{}]: {}",
102        symbol, row.name, row.branch, label
103    )?;
104    Ok(())
105}
106
107fn has_new_commits_vs_pointer(
108    umbrella: &repo::Repo,
109    subrepo_path: &std::path::Path,
110    subrepo: &repo::Repo,
111) -> Result<bool> {
112    let submodule = match subrepo_path.to_str() {
113        Some(path_str) => umbrella.git_repo.find_submodule(path_str)?,
114        None => return Ok(false),
115    };
116
117    let pointer_oid = submodule.index_id().or_else(|| submodule.head_id());
118    let Some(pointer_oid) = pointer_oid else {
119        return Ok(false);
120    };
121
122    let subrepo_head_oid = subrepo.git_repo.head()?.peel_to_commit()?.id();
123    Ok(pointer_oid != subrepo_head_oid)
124}
125
126fn classify_umbrella_status(
127    git_repo: &git2::Repository,
128    config_repos: &[crate::config::Repo],
129) -> Result<RepoLineStatus> {
130    let relevant_entries =
131        collect_relevant_status_entries(git_repo, Some(config_repos))?;
132    if relevant_entries.is_empty() {
133        return Ok(RepoLineStatus::Clean);
134    }
135
136    let only_submodule_paths = relevant_entries.iter().all(|(_, path)| {
137        path.as_ref().is_some_and(|path_str| {
138            config_repos.iter().any(|repo_cfg| {
139                repo_cfg.path.to_string_lossy().as_ref() == path_str.as_str()
140            })
141        })
142    });
143
144    if only_submodule_paths {
145        Ok(RepoLineStatus::NeedsLocking)
146    } else {
147        Ok(RepoLineStatus::HasUncommittedChanges)
148    }
149}
150
151fn is_repo_clean(
152    git_repo: &git2::Repository,
153    config_repos: Option<&[crate::config::Repo]>,
154) -> Result<bool> {
155    Ok(collect_relevant_status_entries(git_repo, config_repos)?.is_empty())
156}
157
158fn collect_relevant_status_entries(
159    git_repo: &git2::Repository,
160    config_repos: Option<&[crate::config::Repo]>,
161) -> Result<Vec<(git2::Status, Option<String>)>> {
162    // Check if there are any uncommitted changes
163    let mut status_options = git2::StatusOptions::new();
164    status_options.include_ignored(false);
165    status_options.include_untracked(true);
166
167    let statuses = git_repo.statuses(Some(&mut status_options))?;
168    let mut relevant_entries = Vec::new();
169
170    // Check if repo is clean - ignore certain files that are expected
171    for entry in statuses.iter() {
172        let status = entry.status();
173        let path = entry.path();
174
175        // If it's just an untracked wok.toml file, we can consider the repo clean
176        if status == git2::Status::WT_NEW && path == Some("wok.toml") {
177            continue;
178        }
179
180        // If it's a newly added .gitmodules file, we can consider the repo clean
181        if status == git2::Status::INDEX_NEW && path == Some(".gitmodules") {
182            continue;
183        }
184
185        // If it's a newly added submodule directory, we can consider the repo clean
186        if status == git2::Status::INDEX_NEW
187            && let Some(path_str) = path
188            && let Some(config_repos) = config_repos
189            && config_repos
190                .iter()
191                .any(|r| r.path.to_string_lossy() == path_str)
192        {
193            continue;
194        }
195
196        relevant_entries.push((status, path.map(str::to_string)));
197    }
198
199    Ok(relevant_entries)
200}