1use std::collections::BTreeMap;
2use std::ffi::OsString;
3use std::path::{Path, PathBuf};
4
5use crate::metadata::RawMetadata;
6use crate::outpost::AheadBehind;
7use crate::source_repo::{
8 SourceRepo, canonicalize_path, invoker_at, is_dirty, read_optional_config,
9};
10use crate::{BranchName, GitInvoker, OutpostError, OutpostResult, RefName, RemoteName};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct StatusReport {
14 pub outpost_path: PathBuf,
15 pub source_path: Option<PathBuf>,
16 pub source_present: bool,
17 pub remote_name: Option<RemoteName>,
18 pub current_branch: Option<BranchName>,
19 pub outpost_dirty: bool,
20 pub source_ahead_behind_upstream: Option<AheadBehind>,
21 pub outpost_ahead_behind_source: Option<AheadBehind>,
22 pub problems: Vec<ConfigProblem>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ConfigProblem {
27 MissingSourceRepoConfig,
28 SourceMissing(PathBuf),
29 MissingRemoteNameConfig,
30 LocalRemoteMismatch {
31 configured: PathBuf,
32 actual: PathBuf,
33 },
34 NoUpstreamTracking {
35 branch: BranchName,
36 },
37 NotInRegistry,
38 PushWouldFail {
39 branch: BranchName,
40 },
41}
42
43pub fn run(target_path: &Path) -> OutpostResult<StatusReport> {
44 run_with(target_path, &BTreeMap::new())
45}
46
47pub fn run_with(
48 target_path: &Path,
49 env: &BTreeMap<OsString, OsString>,
50) -> OutpostResult<StatusReport> {
51 let outpost_path = discover_work_tree(target_path, env)?;
52 let git = invoker_at(&outpost_path, env);
53 let raw = RawMetadata::read(&git)?;
54
55 if raw.managed != Some(true) {
56 return Err(OutpostError::NotAnOutpost(outpost_path));
57 }
58
59 report_from_raw(outpost_path, raw, &git, env)
60}
61
62fn discover_work_tree(
63 target_path: &Path,
64 env: &BTreeMap<OsString, OsString>,
65) -> OutpostResult<PathBuf> {
66 let git = invoker_at(target_path, env);
67 let work_tree = git
68 .run_capture(["rev-parse", "--show-toplevel"])
69 .map_err(|err| map_discovery_error(err, target_path))?;
70 canonicalize_path(Path::new(&work_tree))
71}
72
73fn report_from_raw(
74 outpost_path: PathBuf,
75 raw: RawMetadata,
76 git: &GitInvoker,
77 env: &BTreeMap<OsString, OsString>,
78) -> OutpostResult<StatusReport> {
79 let mut problems = Vec::new();
80 let source_path = match raw.source_repo {
81 Some(path) => Some(canonicalize_existing_or_missing(&path)?),
82 None => {
83 problems.push(ConfigProblem::MissingSourceRepoConfig);
84 None
85 }
86 };
87 if raw.remote_name.is_none() {
88 problems.push(ConfigProblem::MissingRemoteNameConfig);
89 }
90
91 let source_present = source_path.as_ref().is_some_and(|path| path.exists());
92 if let Some(path) = source_path.as_ref().filter(|_| !source_present) {
93 problems.push(ConfigProblem::SourceMissing(path.clone()));
94 }
95 let remote_name = raw.remote_name;
96 let current_branch = current_branch_or_detached(git)?;
97 let outpost_dirty = is_dirty(git)?;
98 let mut source_ahead_behind_upstream = None;
99 let mut outpost_ahead_behind_source = None;
100
101 if let (Some(source_path), Some(remote_name)) = (source_path.as_ref(), remote_name.as_ref()) {
102 if source_present {
103 check_local_remote(git, &outpost_path, source_path, remote_name, &mut problems)?;
104 let source = SourceRepo::at_with(source_path, env)?;
105 check_registry(&source, &outpost_path, &mut problems)?;
106 if let Some(branch) = current_branch.as_ref() {
107 outpost_ahead_behind_source =
108 ahead_behind_outpost_source(git, branch, remote_name, &mut problems)?;
109 source_ahead_behind_upstream =
110 ahead_behind_source_upstream(source_path, branch, env, &mut problems)?;
111 check_push_would_fail(&source, branch, &mut problems)?;
112 }
113 }
114 }
115
116 Ok(StatusReport {
117 outpost_path,
118 source_path,
119 source_present,
120 remote_name,
121 current_branch,
122 outpost_dirty,
123 source_ahead_behind_upstream,
124 outpost_ahead_behind_source,
125 problems,
126 })
127}
128
129fn map_discovery_error(err: OutpostError, path: &Path) -> OutpostError {
130 match err {
131 OutpostError::GitFailed { .. } => OutpostError::NotARepo(path.to_path_buf()),
132 other => other,
133 }
134}
135
136fn canonicalize_existing_or_missing(path: &Path) -> OutpostResult<PathBuf> {
137 if path.exists() {
138 canonicalize_path(path)
139 } else {
140 Ok(canonicalize_missing(path))
141 }
142}
143
144fn canonicalize_missing(path: &Path) -> PathBuf {
145 let Some(parent) = path.parent() else {
146 return path.to_path_buf();
147 };
148 match std::fs::canonicalize(parent) {
149 Ok(parent) => parent.join(path.file_name().unwrap_or_default()),
150 Err(_) => path.to_path_buf(),
151 }
152}
153
154fn current_branch_or_detached(git: &GitInvoker) -> OutpostResult<Option<BranchName>> {
155 match git.run_capture(["symbolic-ref", "--quiet", "--short", "HEAD"]) {
156 Ok(branch) => BranchName::parse(branch).map(Some),
157 Err(OutpostError::GitFailed { code: 1, .. }) => Ok(None),
158 Err(err) => Err(err),
159 }
160}
161
162fn check_local_remote(
163 git: &GitInvoker,
164 outpost_path: &Path,
165 configured: &Path,
166 remote_name: &RemoteName,
167 problems: &mut Vec<ConfigProblem>,
168) -> OutpostResult<()> {
169 let actual = match git.run_capture(["remote", "get-url", remote_name.as_str()]) {
170 Ok(actual) => canonicalize_remote_path(outpost_path, &actual)?,
171 Err(OutpostError::GitFailed { .. }) => return Ok(()),
172 Err(err) => return Err(err),
173 };
174
175 if actual != configured {
176 problems.push(ConfigProblem::LocalRemoteMismatch {
177 configured: configured.to_path_buf(),
178 actual,
179 });
180 }
181
182 Ok(())
183}
184
185fn check_registry(
186 source: &SourceRepo,
187 outpost_path: &Path,
188 problems: &mut Vec<ConfigProblem>,
189) -> OutpostResult<()> {
190 if !source
191 .registry()?
192 .entries()
193 .iter()
194 .any(|entry| entry.path == outpost_path)
195 {
196 problems.push(ConfigProblem::NotInRegistry);
197 }
198
199 Ok(())
200}
201
202fn check_push_would_fail(
203 source: &SourceRepo,
204 branch: &BranchName,
205 problems: &mut Vec<ConfigProblem>,
206) -> OutpostResult<()> {
207 if read_optional_config(source.git(), "receive.denyCurrentBranch")?.as_deref()
208 == Some("updateInstead")
209 {
210 return Ok(());
211 }
212
213 if source
214 .checked_out_branches()?
215 .iter()
216 .any(|checked_out| checked_out == branch)
217 {
218 problems.push(ConfigProblem::PushWouldFail {
219 branch: branch.clone(),
220 });
221 }
222
223 Ok(())
224}
225
226fn ahead_behind_outpost_source(
227 git: &GitInvoker,
228 branch: &BranchName,
229 remote_name: &RemoteName,
230 problems: &mut Vec<ConfigProblem>,
231) -> OutpostResult<Option<AheadBehind>> {
232 let Some(remote_branch) = tracking_branch(git, branch, Some(remote_name), problems)? else {
233 return Ok(None);
234 };
235 let local_ref = format!("refs/heads/{}", branch.as_str());
236 let remote_ref = format!("refs/remotes/{}/{remote_branch}", remote_name.as_str());
237 ahead_behind_existing_refs(git, &local_ref, &remote_ref)
238}
239
240fn ahead_behind_source_upstream(
241 source_path: &Path,
242 branch: &BranchName,
243 env: &BTreeMap<OsString, OsString>,
244 problems: &mut Vec<ConfigProblem>,
245) -> OutpostResult<Option<AheadBehind>> {
246 let source_git = invoker_at(source_path, env);
247 let Some(remote_branch) = tracking_branch(&source_git, branch, None, problems)? else {
248 return Ok(None);
249 };
250 let Some(remote_name) = read_branch_remote(&source_git, branch)? else {
251 return Ok(None);
252 };
253 let local_ref = format!("refs/heads/{}", branch.as_str());
254 let remote_ref = format!("refs/remotes/{}/{remote_branch}", remote_name.as_str());
255 ahead_behind_existing_refs(&source_git, &local_ref, &remote_ref)
256}
257
258fn tracking_branch(
259 git: &GitInvoker,
260 branch: &BranchName,
261 expected_remote: Option<&RemoteName>,
262 problems: &mut Vec<ConfigProblem>,
263) -> OutpostResult<Option<String>> {
264 let Some(remote) = read_branch_remote(git, branch)? else {
265 push_no_upstream_once(problems, branch);
266 return Ok(None);
267 };
268 if expected_remote.is_some_and(|expected| expected != &remote) {
269 push_no_upstream_once(problems, branch);
270 return Ok(None);
271 }
272
273 let merge_key = format!("branch.{}.merge", branch.as_str());
274 let Some(merge_ref) = read_optional_config(git, &merge_key)? else {
275 push_no_upstream_once(problems, branch);
276 return Ok(None);
277 };
278 let merge_ref = RefName::parse(merge_ref)?;
279 let Some(remote_branch) = merge_ref.as_str().strip_prefix("refs/heads/") else {
280 push_no_upstream_once(problems, branch);
281 return Ok(None);
282 };
283
284 Ok(Some(remote_branch.to_owned()))
285}
286
287fn read_branch_remote(git: &GitInvoker, branch: &BranchName) -> OutpostResult<Option<RemoteName>> {
288 let remote_key = format!("branch.{}.remote", branch.as_str());
289 read_optional_config(git, &remote_key)?
290 .map(RemoteName::parse)
291 .transpose()
292}
293
294fn push_no_upstream_once(problems: &mut Vec<ConfigProblem>, branch: &BranchName) {
295 let problem = ConfigProblem::NoUpstreamTracking {
296 branch: branch.clone(),
297 };
298 if !problems.contains(&problem) {
299 problems.push(problem);
300 }
301}
302
303fn ahead_behind_existing_refs(
304 git: &GitInvoker,
305 local_ref: &str,
306 remote_ref: &str,
307) -> OutpostResult<Option<AheadBehind>> {
308 if !ref_exists(git, local_ref)? || !ref_exists(git, remote_ref)? {
309 return Ok(None);
310 }
311
312 let range = format!("{local_ref}...{remote_ref}");
313 let output = git.run_capture(["rev-list", "--left-right", "--count", &range])?;
314 parse_ahead_behind(git.cwd(), &output).map(Some)
315}
316
317fn ref_exists(git: &GitInvoker, ref_name: &str) -> OutpostResult<bool> {
318 git.run_status(["rev-parse", "--verify", "--quiet", ref_name])
319}
320
321fn parse_ahead_behind(repo: &Path, output: &str) -> OutpostResult<AheadBehind> {
322 let mut parts = output.split_whitespace();
323 let ahead = parts
324 .next()
325 .and_then(|value| value.parse::<u32>().ok())
326 .ok_or_else(|| invalid_ahead_behind_output(repo, output))?;
327 let behind = parts
328 .next()
329 .and_then(|value| value.parse::<u32>().ok())
330 .ok_or_else(|| invalid_ahead_behind_output(repo, output))?;
331 if parts.next().is_some() {
332 return Err(invalid_ahead_behind_output(repo, output));
333 }
334
335 Ok(AheadBehind { ahead, behind })
336}
337
338fn invalid_ahead_behind_output(repo: &Path, output: &str) -> OutpostError {
339 OutpostError::IoAt {
340 path: repo.to_path_buf(),
341 source: std::io::Error::new(
342 std::io::ErrorKind::InvalidData,
343 format!("unexpected rev-list output: {output}"),
344 ),
345 }
346}
347
348fn canonicalize_remote_path(outpost_path: &Path, value: &str) -> OutpostResult<PathBuf> {
349 let path = PathBuf::from(value);
350 let path = if path.is_absolute() {
351 path
352 } else {
353 outpost_path.join(path)
354 };
355 canonicalize_existing_or_missing(&path)
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn report_from_raw_records_missing_metadata_problems() {
364 let temp = tempfile::tempdir().expect("tempdir");
365 crate::GitInvoker::at(temp.path())
366 .run_check(["init", "--initial-branch=main"])
367 .expect("init repo");
368
369 let report = report_from_raw(
370 temp.path().to_path_buf(),
371 RawMetadata {
372 managed: Some(true),
373 source_repo: None,
374 remote_name: None,
375 },
376 &crate::GitInvoker::at(temp.path()),
377 &BTreeMap::new(),
378 )
379 .expect("report");
380
381 assert_eq!(report.source_path, None);
382 assert!(!report.source_present);
383 assert_eq!(report.remote_name, None);
384 assert_eq!(
385 report.problems,
386 vec![
387 ConfigProblem::MissingSourceRepoConfig,
388 ConfigProblem::MissingRemoteNameConfig,
389 ]
390 );
391 }
392}