Skip to main content

outpost_core/ops/
status.rs

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    canonicalize_path, invoker_at, is_dirty, read_optional_config, SourceRepo,
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}