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 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 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 for entry in statuses.iter() {
172 let status = entry.status();
173 let path = entry.path();
174
175 if status == git2::Status::WT_NEW && path == Some("wok.toml") {
177 continue;
178 }
179
180 if status == git2::Status::INDEX_NEW && path == Some(".gitmodules") {
182 continue;
183 }
184
185 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}