1use std::collections::BTreeMap;
2use std::ffi::OsString;
3use std::path::{Path, PathBuf};
4
5use crate::metadata::{Metadata, RawMetadata};
6use crate::source_repo::{
7 canonicalize_path, current_branch, invoker_at, is_dirty, read_optional_config, SourceRepo,
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!(matches!(err, OutpostError::NotAnOutpost(path) if path == temp.path()));
279 }
280
281 #[test]
282 fn outpost_at_reads_metadata_and_source_repo() {
283 let temp = tempfile::tempdir().expect("tempdir");
284 let source = temp.path().join("source");
285 let outpost = temp.path().join("outpost");
286 init_repo(&source);
287 init_repo(&outpost);
288 let metadata = Metadata {
289 source_repo: source.clone(),
290 remote_name: RemoteName::parse("local").unwrap(),
291 };
292 metadata.write(&GitInvoker::at(&outpost)).unwrap();
293
294 let outpost = Outpost::at(&outpost).expect("managed outpost");
295
296 assert_eq!(outpost.metadata().remote_name.as_str(), "local");
297 assert_eq!(
298 outpost.source_repo().unwrap().work_tree(),
299 fs::canonicalize(&source).unwrap()
300 );
301 }
302
303 #[test]
304 fn outpost_reports_missing_source_repo_from_metadata() {
305 let temp = tempfile::tempdir().expect("tempdir");
306 let source = temp.path().join("source");
307 let outpost = temp.path().join("outpost");
308 init_repo(&source);
309 init_repo(&outpost);
310 let metadata = Metadata {
311 source_repo: source.clone(),
312 remote_name: RemoteName::parse("local").unwrap(),
313 };
314 metadata.write(&GitInvoker::at(&outpost)).unwrap();
315 fs::remove_dir_all(&source).expect("remove source");
316
317 let outpost = Outpost::at(&outpost).expect("managed outpost");
318 let Err(err) = outpost.source_repo() else {
319 panic!("source should be missing");
320 };
321
322 assert!(
323 matches!(err, OutpostError::SourceMissing(path) if path == fs::canonicalize(temp.path()).unwrap().join("source"))
324 );
325 }
326
327 #[test]
328 fn unpushed_commits_reports_local_commits_ahead_of_source() {
329 let temp = tempfile::tempdir().expect("tempdir");
330 let source = temp.path().join("source");
331 let outpost = temp.path().join("outpost");
332 init_repo(&source);
333 init_repo(&outpost);
334 let source_git = GitInvoker::at(&source);
335 source_git
336 .run_check(["commit", "--allow-empty", "-m", "source"])
337 .expect("source commit");
338 let outpost_git = GitInvoker::at(&outpost);
339 outpost_git
340 .run_check(["pull", &source.to_string_lossy(), "main"])
341 .expect("pull source into outpost");
342 outpost_git
343 .run_check(["remote", "add", "local", &source.to_string_lossy()])
344 .expect("add source remote");
345 outpost_git
346 .run_check(["fetch", "local", "main"])
347 .expect("fetch source remote");
348 outpost_git
349 .run_check(["branch", "--set-upstream-to", "local/main", "main"])
350 .expect("set upstream");
351 let metadata = Metadata {
352 source_repo: source.clone(),
353 remote_name: RemoteName::parse("local").unwrap(),
354 };
355 metadata.write(&outpost_git).unwrap();
356 outpost_git
357 .run_check(["commit", "--allow-empty", "-m", "outpost"])
358 .expect("outpost commit");
359
360 let source = SourceRepo::at(&source).expect("source repo");
361 let outpost = Outpost::at(&outpost).expect("outpost");
362
363 assert_eq!(outpost.unpushed_commits(&source).expect("unpushed"), 1);
364 }
365
366 fn init_repo(path: &Path) {
367 fs::create_dir_all(path).expect("repo dir");
368 let git = GitInvoker::at(path);
369 git.run_check(["init", "--initial-branch=main"])
370 .expect("init");
371 git.run_check(["config", "user.name", "Test Author"])
372 .expect("set user.name");
373 git.run_check(["config", "user.email", "test@example.com"])
374 .expect("set user.email");
375 }
376}