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}