Skip to main content

outpost_core/
outpost.rs

1use std::collections::BTreeMap;
2use std::ffi::OsString;
3use std::path::{Path, PathBuf};
4
5use crate::metadata::{Metadata, RawMetadata};
6use crate::source_repo::{
7    SourceRepo, canonicalize_path, current_branch, invoker_at, is_dirty, read_optional_config,
8};
9use crate::{BranchName, GitInvoker, OutpostError, OutpostResult, RefName, UpstreamRef};
10
11pub struct Outpost {
12    work_tree: PathBuf,
13    git_dir: PathBuf,
14    git: GitInvoker,
15    metadata: Metadata,
16    env: BTreeMap<OsString, OsString>,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct AheadBehind {
21    pub ahead: u32,
22    pub behind: u32,
23}
24
25impl Outpost {
26    pub fn discover(start: &Path) -> OutpostResult<Self> {
27        Self::discover_with(start, &BTreeMap::new())
28    }
29
30    pub fn discover_with(start: &Path, env: &BTreeMap<OsString, OsString>) -> OutpostResult<Self> {
31        let git = invoker_at(start, env);
32        let work_tree = git
33            .run_capture(["rev-parse", "--show-toplevel"])
34            .map_err(|err| map_discovery_error(err, start))?;
35        Self::at_with(work_tree, env)
36    }
37
38    pub fn at(path: impl Into<PathBuf>) -> OutpostResult<Self> {
39        Self::at_with(path, &BTreeMap::new())
40    }
41
42    pub fn at_with(
43        path: impl Into<PathBuf>,
44        env: &BTreeMap<OsString, OsString>,
45    ) -> OutpostResult<Self> {
46        let start = path.into();
47        let git = invoker_at(&start, env);
48        let work_tree_raw = git
49            .run_capture(["rev-parse", "--show-toplevel"])
50            .map_err(|err| map_discovery_error(err, &start))?;
51        let git_dir_raw = git
52            .run_capture(["rev-parse", "--git-dir"])
53            .map_err(|err| map_discovery_error(err, &start))?;
54        let work_tree = canonicalize_path(Path::new(&work_tree_raw))?;
55        let git_dir = canonicalize_git_path(&start, &git_dir_raw)?;
56        let git = invoker_at(&work_tree, env);
57        let raw = RawMetadata::read(&git)?;
58        let metadata = Metadata::from_raw(&work_tree, raw)?;
59
60        Ok(Self {
61            work_tree,
62            git_dir,
63            git,
64            metadata,
65            env: env.clone(),
66        })
67    }
68
69    pub fn work_tree(&self) -> &Path {
70        &self.work_tree
71    }
72
73    pub fn git_dir(&self) -> &Path {
74        &self.git_dir
75    }
76
77    pub fn metadata(&self) -> &Metadata {
78        &self.metadata
79    }
80
81    pub fn source_repo(&self) -> OutpostResult<SourceRepo> {
82        if !self.metadata.source_repo.exists() {
83            return Err(OutpostError::SourceMissing(
84                self.metadata.source_repo.clone(),
85            ));
86        }
87        SourceRepo::at_with(&self.metadata.source_repo, &self.env)
88    }
89
90    pub fn current_branch(&self) -> OutpostResult<BranchName> {
91        current_branch(&self.git, &self.work_tree)
92    }
93
94    pub fn is_dirty(&self) -> OutpostResult<bool> {
95        is_dirty(&self.git)
96    }
97
98    pub fn ahead_behind_source(&self) -> OutpostResult<AheadBehind> {
99        let branch = self.current_branch()?;
100        let upstream =
101            self.upstream_tracking()?
102                .ok_or_else(|| OutpostError::NoUpstreamTracking {
103                    branch: branch.as_str().to_owned(),
104                })?;
105        if upstream.remote != self.metadata.remote_name {
106            return Err(OutpostError::NoUpstreamTracking {
107                branch: branch.as_str().to_owned(),
108            });
109        }
110        let remote_branch =
111            upstream
112                .short_branch()
113                .ok_or_else(|| OutpostError::UpstreamNotABranch {
114                    merge_ref: upstream.merge_ref.as_str().to_owned(),
115                })?;
116        let remote_tracking_ref = format!(
117            "refs/remotes/{}/{}",
118            self.metadata.remote_name.as_str(),
119            remote_branch
120        );
121        let fetch_refspec = format!("{}:{remote_tracking_ref}", upstream.merge_ref.as_str());
122        self.git
123            .run_check(["fetch", self.metadata.remote_name.as_str(), &fetch_refspec])?;
124
125        let local_ref = format!("refs/heads/{}", branch.as_str());
126        let range = format!("{local_ref}...{remote_tracking_ref}");
127        parse_ahead_behind(
128            &self.work_tree,
129            self.git
130                .run_capture(["rev-list", "--left-right", "--count", &range])?,
131        )
132    }
133
134    pub fn unpushed_commits(&self, source: &SourceRepo) -> OutpostResult<u32> {
135        let branch = self.current_branch()?;
136        if !source.branch_exists(&branch)? {
137            return Err(OutpostError::BranchNotFound {
138                branch: branch.as_str().to_owned(),
139                repo: source.work_tree().to_path_buf(),
140            });
141        }
142
143        let upstream =
144            self.upstream_tracking()?
145                .ok_or_else(|| OutpostError::NoUpstreamTracking {
146                    branch: branch.as_str().to_owned(),
147                })?;
148        if upstream.remote != self.metadata.remote_name {
149            return Err(OutpostError::NoUpstreamTracking {
150                branch: branch.as_str().to_owned(),
151            });
152        }
153        let remote_branch =
154            upstream
155                .short_branch()
156                .ok_or_else(|| OutpostError::UpstreamNotABranch {
157                    merge_ref: upstream.merge_ref.as_str().to_owned(),
158                })?;
159        if remote_branch != branch.as_str() {
160            return Err(OutpostError::NoUpstreamTracking {
161                branch: branch.as_str().to_owned(),
162            });
163        }
164
165        let remote_tracking_ref = format!(
166            "refs/remotes/{}/{}",
167            self.metadata.remote_name.as_str(),
168            remote_branch
169        );
170        let fetch_refspec = format!("{}:{remote_tracking_ref}", upstream.merge_ref.as_str());
171        self.git
172            .run_check(["fetch", self.metadata.remote_name.as_str(), &fetch_refspec])?;
173        let local_ref = format!("refs/heads/{}", branch.as_str());
174        let range = format!("{remote_tracking_ref}..{local_ref}");
175        let output = self.git.run_capture(["rev-list", "--count", &range])?;
176        parse_count(&self.work_tree, &output)
177    }
178
179    pub fn upstream_tracking(&self) -> OutpostResult<Option<UpstreamRef>> {
180        let branch = self.current_branch()?;
181        let remote_key = format!("branch.{}.remote", branch.as_str());
182        let merge_key = format!("branch.{}.merge", branch.as_str());
183        let Some(remote) = read_optional_config(&self.git, &remote_key)? else {
184            return Ok(None);
185        };
186        let Some(merge_ref) = read_optional_config(&self.git, &merge_key)? else {
187            return Ok(None);
188        };
189
190        Ok(Some(UpstreamRef {
191            remote: crate::RemoteName::parse(remote)?,
192            merge_ref: RefName::parse(merge_ref)?,
193        }))
194    }
195
196    pub(crate) fn git(&self) -> &GitInvoker {
197        &self.git
198    }
199
200    #[cfg(any(test, feature = "test-helpers"))]
201    pub fn test_invoker(&self) -> &GitInvoker {
202        &self.git
203    }
204}
205
206fn parse_ahead_behind(repo: &Path, output: String) -> OutpostResult<AheadBehind> {
207    let mut parts = output.split_whitespace();
208    let ahead = parts
209        .next()
210        .and_then(|value| value.parse::<u32>().ok())
211        .ok_or_else(|| invalid_ahead_behind_output(repo, &output))?;
212    let behind = parts
213        .next()
214        .and_then(|value| value.parse::<u32>().ok())
215        .ok_or_else(|| invalid_ahead_behind_output(repo, &output))?;
216    if parts.next().is_some() {
217        return Err(invalid_ahead_behind_output(repo, &output));
218    }
219
220    Ok(AheadBehind { ahead, behind })
221}
222
223fn invalid_ahead_behind_output(repo: &Path, output: &str) -> OutpostError {
224    OutpostError::IoAt {
225        path: repo.to_path_buf(),
226        source: std::io::Error::new(
227            std::io::ErrorKind::InvalidData,
228            format!("unexpected rev-list output: {output}"),
229        ),
230    }
231}
232
233fn parse_count(repo: &Path, output: &str) -> OutpostResult<u32> {
234    let count = output
235        .split_whitespace()
236        .next()
237        .and_then(|value| value.parse::<u32>().ok())
238        .ok_or_else(|| invalid_ahead_behind_output(repo, output))?;
239    if output.split_whitespace().nth(1).is_some() {
240        return Err(invalid_ahead_behind_output(repo, output));
241    }
242    Ok(count)
243}
244
245fn canonicalize_git_path(start: &Path, value: &str) -> OutpostResult<PathBuf> {
246    let path = PathBuf::from(value);
247    if path.is_absolute() {
248        canonicalize_path(&path)
249    } else {
250        canonicalize_path(&start.join(path))
251    }
252}
253
254fn map_discovery_error(err: OutpostError, path: &Path) -> OutpostError {
255    match err {
256        OutpostError::GitFailed { .. } => OutpostError::NotARepo(path.to_path_buf()),
257        other => other,
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use std::fs;
264
265    use super::*;
266    use crate::RemoteName;
267
268    #[test]
269    fn outpost_at_rejects_unmanaged_repo() {
270        let temp = tempfile::tempdir().expect("tempdir");
271        GitInvoker::at(temp.path())
272            .run_check(["init", "--initial-branch=main"])
273            .expect("init");
274
275        let Err(err) = Outpost::at(temp.path()) else {
276            panic!("unmanaged repo should fail");
277        };
278        assert!(
279            matches!(err, OutpostError::NotAnOutpost(path) if path == fs::canonicalize(temp.path()).unwrap())
280        );
281    }
282
283    #[test]
284    fn outpost_at_reads_metadata_and_source_repo() {
285        let temp = tempfile::tempdir().expect("tempdir");
286        let source = temp.path().join("source");
287        let outpost = temp.path().join("outpost");
288        init_repo(&source);
289        init_repo(&outpost);
290        let metadata = Metadata {
291            source_repo: source.clone(),
292            remote_name: RemoteName::parse("local").unwrap(),
293        };
294        metadata.write(&GitInvoker::at(&outpost)).unwrap();
295
296        let outpost = Outpost::at(&outpost).expect("managed outpost");
297
298        assert_eq!(outpost.metadata().remote_name.as_str(), "local");
299        assert_eq!(
300            outpost.source_repo().unwrap().work_tree(),
301            fs::canonicalize(&source).unwrap()
302        );
303    }
304
305    #[test]
306    fn outpost_reports_missing_source_repo_from_metadata() {
307        let temp = tempfile::tempdir().expect("tempdir");
308        let source = temp.path().join("source");
309        let outpost = temp.path().join("outpost");
310        init_repo(&source);
311        init_repo(&outpost);
312        let metadata = Metadata {
313            source_repo: source.clone(),
314            remote_name: RemoteName::parse("local").unwrap(),
315        };
316        metadata.write(&GitInvoker::at(&outpost)).unwrap();
317        fs::remove_dir_all(&source).expect("remove source");
318
319        let outpost = Outpost::at(&outpost).expect("managed outpost");
320        let Err(err) = outpost.source_repo() else {
321            panic!("source should be missing");
322        };
323
324        assert!(
325            matches!(err, OutpostError::SourceMissing(path) if path == fs::canonicalize(temp.path()).unwrap().join("source"))
326        );
327    }
328
329    #[test]
330    fn unpushed_commits_reports_local_commits_ahead_of_source() {
331        let temp = tempfile::tempdir().expect("tempdir");
332        let source = temp.path().join("source");
333        let outpost = temp.path().join("outpost");
334        init_repo(&source);
335        init_repo(&outpost);
336        let source_git = GitInvoker::at(&source);
337        source_git
338            .run_check(["commit", "--allow-empty", "-m", "source"])
339            .expect("source commit");
340        let outpost_git = GitInvoker::at(&outpost);
341        outpost_git
342            .run_check(["pull", &source.to_string_lossy(), "main"])
343            .expect("pull source into outpost");
344        outpost_git
345            .run_check(["remote", "add", "local", &source.to_string_lossy()])
346            .expect("add source remote");
347        outpost_git
348            .run_check(["fetch", "local", "main"])
349            .expect("fetch source remote");
350        outpost_git
351            .run_check(["branch", "--set-upstream-to", "local/main", "main"])
352            .expect("set upstream");
353        let metadata = Metadata {
354            source_repo: source.clone(),
355            remote_name: RemoteName::parse("local").unwrap(),
356        };
357        metadata.write(&outpost_git).unwrap();
358        outpost_git
359            .run_check(["commit", "--allow-empty", "-m", "outpost"])
360            .expect("outpost commit");
361
362        let source = SourceRepo::at(&source).expect("source repo");
363        let outpost = Outpost::at(&outpost).expect("outpost");
364
365        assert_eq!(outpost.unpushed_commits(&source).expect("unpushed"), 1);
366    }
367
368    fn init_repo(path: &Path) {
369        fs::create_dir_all(path).expect("repo dir");
370        let git = GitInvoker::at(path);
371        git.run_check(["init", "--initial-branch=main"])
372            .expect("init");
373        git.run_check(["config", "user.name", "Test Author"])
374            .expect("set user.name");
375        git.run_check(["config", "user.email", "test@example.com"])
376            .expect("set user.email");
377    }
378}