1use std::collections::BTreeMap;
2use std::ffi::OsString;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::outpost::Outpost;
7use crate::registry::{Registry, RegistryMut};
8use crate::{
9 BranchName, GitInvoker, OutpostError, OutpostResult, RefName, RemoteName, UpstreamRef,
10};
11
12pub struct SourceRepo {
13 work_tree: PathBuf,
14 git_dir: PathBuf,
15 git_common_dir: PathBuf,
16 git: GitInvoker,
17 env: BTreeMap<OsString, OsString>,
18}
19
20impl SourceRepo {
21 pub fn discover(start: &Path) -> OutpostResult<Self> {
22 Self::discover_with(start, &BTreeMap::new())
23 }
24
25 pub fn discover_with(start: &Path, env: &BTreeMap<OsString, OsString>) -> OutpostResult<Self> {
26 let git = invoker_at(start, env);
27 let work_tree = git
28 .run_capture(["rev-parse", "--show-toplevel"])
29 .map_err(|err| map_discovery_error(err, start))?;
30 Self::at_with(work_tree, env)
31 }
32
33 pub fn at(path: impl Into<PathBuf>) -> OutpostResult<Self> {
34 Self::at_with(path, &BTreeMap::new())
35 }
36
37 pub fn at_with(
38 path: impl Into<PathBuf>,
39 env: &BTreeMap<OsString, OsString>,
40 ) -> OutpostResult<Self> {
41 let start = path.into();
42 let git = invoker_at(&start, env);
43
44 let work_tree_raw = git
45 .run_capture(["rev-parse", "--show-toplevel"])
46 .map_err(|err| map_discovery_error(err, &start))?;
47 let git_dir_raw = git
48 .run_capture(["rev-parse", "--git-dir"])
49 .map_err(|err| map_discovery_error(err, &start))?;
50 let git_common_dir_raw = git
51 .run_capture(["rev-parse", "--git-common-dir"])
52 .map_err(|err| map_discovery_error(err, &start))?;
53
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_common_dir = canonicalize_git_path(&start, &git_common_dir_raw)?;
57 let git = invoker_at(&work_tree, env);
58
59 Ok(Self {
60 work_tree,
61 git_dir,
62 git_common_dir,
63 git,
64 env: env.clone(),
65 })
66 }
67
68 pub fn work_tree(&self) -> &Path {
69 &self.work_tree
70 }
71
72 pub fn git_dir(&self) -> &Path {
73 &self.git_dir
74 }
75
76 pub fn git_common_dir(&self) -> &Path {
77 &self.git_common_dir
78 }
79
80 pub fn outpost_at(&self, path: &Path) -> OutpostResult<Outpost> {
81 Outpost::at_with(path, &self.env)
82 }
83
84 pub fn env(&self) -> &BTreeMap<OsString, OsString> {
85 &self.env
86 }
87
88 #[cfg(any(test, feature = "test-helpers"))]
89 pub fn test_invoker(&self) -> &GitInvoker {
90 &self.git
91 }
92
93 pub fn current_branch(&self) -> OutpostResult<BranchName> {
94 current_branch(&self.git, &self.work_tree)
95 }
96
97 pub fn checked_out_branches(&self) -> OutpostResult<Vec<BranchName>> {
98 let mut branches = Vec::new();
99 if let Ok(branch) = self.current_branch() {
100 branches.push(branch);
101 }
102
103 let output = self.git.run_capture(["worktree", "list", "--porcelain"])?;
104 for line in output.lines() {
105 if let Some(branch) = line.strip_prefix("branch refs/heads/") {
106 let branch = BranchName::parse(branch.to_owned())?;
107 if !branches.iter().any(|existing| existing == &branch) {
108 branches.push(branch);
109 }
110 }
111 }
112 Ok(branches)
113 }
114
115 pub fn checked_out_worktree_for(&self, branch: &BranchName) -> OutpostResult<Option<PathBuf>> {
116 let output = self.git.run_capture(["worktree", "list", "--porcelain"])?;
117 let mut current_path: Option<PathBuf> = None;
118 for line in output.lines() {
119 if let Some(path) = line.strip_prefix("worktree ") {
120 current_path = Some(canonicalize_path(Path::new(path))?);
121 } else if let Some(value) = line.strip_prefix("branch refs/heads/") {
122 if value == branch.as_str() {
123 return Ok(current_path);
124 }
125 }
126 }
127 Ok(None)
128 }
129
130 pub fn is_dirty(&self) -> OutpostResult<bool> {
131 is_dirty(&self.git)
132 }
133
134 pub fn upstream_for(&self, branch: &BranchName) -> OutpostResult<Option<UpstreamRef>> {
135 let remote_key = format!("branch.{}.remote", branch.as_str());
136 let merge_key = format!("branch.{}.merge", branch.as_str());
137 let Some(remote) = read_optional_config(&self.git, &remote_key)? else {
138 return Ok(None);
139 };
140 let Some(merge_ref) = read_optional_config(&self.git, &merge_key)? else {
141 return Ok(None);
142 };
143
144 Ok(Some(UpstreamRef {
145 remote: crate::RemoteName::parse(remote)?,
146 merge_ref: RefName::parse(merge_ref)?,
147 }))
148 }
149
150 pub fn remote_url(&self, remote: &RemoteName) -> OutpostResult<String> {
151 self.git.run_capture(["remote", "get-url", remote.as_str()])
152 }
153
154 pub fn branch_exists(&self, branch: &BranchName) -> OutpostResult<bool> {
155 let branch_ref = format!("refs/heads/{}", branch.as_str());
156 self.git
157 .run_status(["rev-parse", "--verify", "--quiet", &branch_ref])
158 }
159
160 pub fn branch_oid(&self, branch: &BranchName) -> OutpostResult<Option<String>> {
161 if !self.branch_exists(branch)? {
162 return Ok(None);
163 }
164
165 rev_parse(&self.git, &source_branch_ref(branch)).map(|oid| Some(oid.trim().to_owned()))
166 }
167
168 pub fn origin_branch_oid(&self, branch: &BranchName) -> OutpostResult<Option<String>> {
169 self.remote_branch_oid(&origin_remote(), branch)
170 }
171
172 pub fn remote_branch_oid(
173 &self,
174 remote: &RemoteName,
175 branch: &BranchName,
176 ) -> OutpostResult<Option<String>> {
177 let remote_ref = source_branch_ref(branch);
178 let output = self
179 .git
180 .run_capture(["ls-remote", remote.as_str(), &remote_ref])?;
181 if output.is_empty() {
182 return Ok(None);
183 }
184
185 let mut fields = output.split_whitespace();
186 let oid = fields
187 .next()
188 .ok_or_else(|| invalid_git_output(&self.git, &output))?;
189 let name = fields
190 .next()
191 .ok_or_else(|| invalid_git_output(&self.git, &output))?;
192 if fields.next().is_some() || name != remote_ref {
193 return Err(invalid_git_output(&self.git, &output));
194 }
195
196 Ok(Some(oid.to_owned()))
197 }
198
199 pub fn origin_default_branch(&self) -> OutpostResult<Option<BranchName>> {
200 self.remote_default_branch(&origin_remote())
201 }
202
203 pub fn remote_default_branch(&self, remote: &RemoteName) -> OutpostResult<Option<BranchName>> {
204 let head_ref = format!("refs/remotes/{}/HEAD", remote.as_str());
205 if !self
206 .git
207 .run_status(["symbolic-ref", "--quiet", &head_ref])?
208 {
209 return Ok(None);
210 }
211
212 let reference = self
213 .git
214 .run_capture(["symbolic-ref", "--quiet", &head_ref])?;
215 let remote_prefix = format!("refs/remotes/{}/", remote.as_str());
216 let Some(branch) = reference.strip_prefix(&remote_prefix) else {
217 return Err(invalid_git_output(&self.git, &reference));
218 };
219
220 BranchName::parse(branch.to_owned()).map(Some)
221 }
222
223 pub fn fetch_origin_default_branch(&self) -> OutpostResult<Option<(BranchName, String)>> {
224 self.fetch_remote_default_branch(&origin_remote())
225 }
226
227 pub fn fetch_remote_default_branch(
228 &self,
229 remote: &RemoteName,
230 ) -> OutpostResult<Option<(BranchName, String)>> {
231 let branch = match self.remote_default_branch(remote)? {
232 Some(branch) => Some(branch),
233 None => self.remote_head_branch(remote)?,
234 };
235 let Some(branch) = branch else {
236 return Ok(None);
237 };
238
239 let remote_tracking_ref = format!("refs/remotes/{}/{}", remote.as_str(), branch.as_str());
240 let fetch_refspec = format!("+{}:{remote_tracking_ref}", source_branch_ref(&branch));
241 self.git
242 .run_check(["fetch", remote.as_str(), &fetch_refspec])?;
243 let oid = rev_parse(&self.git, &remote_tracking_ref)?;
244
245 Ok(Some((branch, oid.trim().to_owned())))
246 }
247
248 fn remote_head_branch(&self, remote: &RemoteName) -> OutpostResult<Option<BranchName>> {
249 let output = self
250 .git
251 .run_capture(["ls-remote", "--symref", remote.as_str(), "HEAD"])?;
252 for line in output.lines() {
253 let Some(rest) = line.strip_prefix("ref: ") else {
254 continue;
255 };
256 let mut fields = rest.split_whitespace();
257 let Some(reference) = fields.next() else {
258 return Err(invalid_git_output(&self.git, &output));
259 };
260 let Some(name) = fields.next() else {
261 return Err(invalid_git_output(&self.git, &output));
262 };
263 if fields.next().is_some() {
264 return Err(invalid_git_output(&self.git, &output));
265 }
266 if name != "HEAD" {
267 continue;
268 }
269 let Some(branch) = reference.strip_prefix("refs/heads/") else {
270 return Err(invalid_git_output(&self.git, &output));
271 };
272 return BranchName::parse(branch.to_owned()).map(Some);
273 }
274 Ok(None)
275 }
276
277 pub fn is_ancestor_oid(&self, ancestor: &str, descendant: &str) -> OutpostResult<bool> {
278 is_ancestor(&self.git, ancestor, descendant)
279 }
280
281 pub fn is_branch_checked_out(&self, branch: &BranchName) -> OutpostResult<bool> {
282 self.checked_out_worktree_for(branch)
283 .map(|path| path.is_some())
284 }
285
286 pub fn delete_branch_if_oid(
287 &self,
288 branch: &BranchName,
289 expected_oid: &str,
290 ) -> OutpostResult<()> {
291 self.git
292 .run_check(["update-ref", "-d", &source_branch_ref(branch), expected_oid])
293 }
294
295 pub fn delete_origin_branch_if_oid(
296 &self,
297 branch: &BranchName,
298 expected_oid: &str,
299 ) -> OutpostResult<()> {
300 self.delete_remote_branch_if_oid(&origin_remote(), branch, expected_oid)
301 }
302
303 pub fn delete_remote_branch_if_oid(
304 &self,
305 remote: &RemoteName,
306 branch: &BranchName,
307 expected_oid: &str,
308 ) -> OutpostResult<()> {
309 let lease = format!(
310 "--force-with-lease=refs/heads/{}:{expected_oid}",
311 branch.as_str()
312 );
313 let delete_refspec = format!(":refs/heads/{}", branch.as_str());
314 self.git
315 .run_check(["push", &lease, remote.as_str(), &delete_refspec])
316 }
317
318 pub fn fast_forward_branch_from_origin(&self, branch: &BranchName) -> OutpostResult<()> {
319 if !self.branch_exists(branch)? {
320 return Err(OutpostError::BranchNotFound {
321 branch: branch.as_str().to_owned(),
322 repo: self.work_tree.clone(),
323 });
324 }
325
326 let local_ref = format!("refs/heads/{}", branch.as_str());
327 let remote_ref = format!("refs/remotes/origin/{}", branch.as_str());
328 let fetch_refspec = format!("{}:{remote_ref}", branch.as_str());
329 self.git.run_check(["fetch", "origin", &fetch_refspec])?;
330
331 let local_oid = rev_parse(&self.git, &local_ref)?;
332 let remote_oid = rev_parse(&self.git, &remote_ref)?;
333 if local_oid == remote_oid || is_ancestor(&self.git, &remote_oid, &local_oid)? {
334 return Ok(());
335 }
336 if !is_ancestor(&self.git, &local_oid, &remote_oid)? {
337 return Err(OutpostError::Divergence {
338 branch: branch.as_str().to_owned(),
339 });
340 }
341
342 if let Some(worktree) = self.checked_out_worktree_for(branch)? {
343 let git = invoker_at(&worktree, &self.env);
344 git.run_check(["merge", "--ff-only", &remote_ref])?;
345 } else {
346 self.git
347 .run_check(["update-ref", &local_ref, &remote_oid, &local_oid])?;
348 }
349
350 Ok(())
351 }
352
353 pub fn registry_path(&self) -> PathBuf {
354 self.work_tree.join(".outpost").join("registry.json")
355 }
356
357 pub fn registry(&self) -> OutpostResult<Registry> {
358 Registry::load(self)
359 }
360
361 pub fn registry_mut(&self) -> OutpostResult<RegistryMut<'_>> {
362 RegistryMut::load(self)
363 }
364
365 pub(crate) fn local_exclude_path(&self) -> PathBuf {
366 self.git_dir.join("info").join("exclude")
367 }
368
369 pub(crate) fn git(&self) -> &GitInvoker {
370 &self.git
371 }
372
373 #[cfg(test)]
374 pub(crate) fn from_storage_paths(work_tree: &Path, git_dir: &Path) -> OutpostResult<Self> {
375 let work_tree = canonicalize_path(work_tree)?;
376 let git_dir = canonicalize_path(git_dir)?;
377 Ok(Self {
378 git_common_dir: git_dir.clone(),
379 git: GitInvoker::at(&work_tree),
380 env: BTreeMap::new(),
381 work_tree,
382 git_dir,
383 })
384 }
385}
386
387pub(crate) fn invoker_at(cwd: &Path, env: &BTreeMap<OsString, OsString>) -> GitInvoker {
388 env.iter().fold(GitInvoker::at(cwd), |git, (key, val)| {
389 git.with_env(key.clone(), val.clone())
390 })
391}
392
393pub(crate) fn current_branch(git: &GitInvoker, repo: &Path) -> OutpostResult<BranchName> {
394 let name = git
395 .run_capture(["symbolic-ref", "--quiet", "--short", "HEAD"])
396 .map_err(|err| match err {
397 OutpostError::GitFailed { .. } => OutpostError::BranchNotFound {
398 branch: "HEAD".to_owned(),
399 repo: repo.to_path_buf(),
400 },
401 other => other,
402 })?;
403 BranchName::parse(name)
404}
405
406pub(crate) fn is_dirty(git: &GitInvoker) -> OutpostResult<bool> {
407 Ok(!git
408 .run_capture(["status", "--porcelain=v1", "--untracked-files=normal"])?
409 .is_empty())
410}
411
412pub(crate) fn read_optional_config(git: &GitInvoker, key: &str) -> OutpostResult<Option<String>> {
413 if git.run_status(["config", "--local", "--get", key])? {
414 git.run_capture(["config", "--local", "--get", key])
415 .map(Some)
416 } else {
417 Ok(None)
418 }
419}
420
421pub(crate) fn rev_parse(git: &GitInvoker, reference: &str) -> OutpostResult<String> {
422 git.run_capture(["rev-parse", reference])
423}
424
425pub(crate) fn is_ancestor(
426 git: &GitInvoker,
427 ancestor: &str,
428 descendant: &str,
429) -> OutpostResult<bool> {
430 git.run_status(["merge-base", "--is-ancestor", ancestor, descendant])
431}
432
433pub(crate) fn canonicalize_path(path: &Path) -> OutpostResult<PathBuf> {
434 fs::canonicalize(path).map_err(|source| OutpostError::IoAt {
435 path: path.to_path_buf(),
436 source,
437 })
438}
439
440fn canonicalize_git_path(start: &Path, value: &str) -> OutpostResult<PathBuf> {
441 let path = PathBuf::from(value);
442 if path.is_absolute() {
443 canonicalize_path(&path)
444 } else {
445 canonicalize_path(&start.join(path))
446 }
447}
448
449fn map_discovery_error(err: OutpostError, path: &Path) -> OutpostError {
450 match err {
451 OutpostError::GitFailed { .. } => OutpostError::NotARepo(path.to_path_buf()),
452 other => other,
453 }
454}
455
456fn source_branch_ref(branch: &BranchName) -> String {
457 format!("refs/heads/{}", branch.as_str())
458}
459
460fn origin_remote() -> RemoteName {
461 RemoteName::parse("origin").expect("origin is a valid remote name")
462}
463
464fn invalid_git_output(git: &GitInvoker, output: &str) -> OutpostError {
465 OutpostError::IoAt {
466 path: git.cwd().to_path_buf(),
467 source: std::io::Error::new(
468 std::io::ErrorKind::InvalidData,
469 format!("unexpected git output: {output}"),
470 ),
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use std::fs;
477
478 use super::*;
479
480 #[test]
481 fn source_at_canonicalizes_paths_and_reads_current_branch() {
482 let temp = tempfile::tempdir().expect("tempdir");
483 GitInvoker::at(temp.path())
484 .run_check(["init", "--initial-branch=main"])
485 .expect("init");
486 let source = SourceRepo::at(temp.path()).expect("source repo");
487
488 assert_eq!(source.work_tree(), fs::canonicalize(temp.path()).unwrap());
489 assert_eq!(
490 source.git_dir(),
491 fs::canonicalize(temp.path().join(".git")).unwrap()
492 );
493 assert_eq!(
494 source.git_common_dir(),
495 fs::canonicalize(temp.path().join(".git")).unwrap()
496 );
497 assert_eq!(source.current_branch().unwrap().as_str(), "main");
498 assert!(!source.is_dirty().unwrap());
499 }
500
501 #[test]
502 fn source_discover_rejects_non_repo() {
503 let temp = tempfile::tempdir().expect("tempdir");
504 let Err(err) = SourceRepo::discover(temp.path()) else {
505 panic!("non repo should fail");
506 };
507
508 assert!(matches!(err, OutpostError::NotARepo(path) if path == temp.path()));
509 }
510
511 #[test]
512 fn source_dirty_detects_untracked_files() {
513 let temp = tempfile::tempdir().expect("tempdir");
514 GitInvoker::at(temp.path())
515 .run_check(["init", "--initial-branch=main"])
516 .expect("init");
517 fs::write(temp.path().join("new.txt"), "dirty").expect("write untracked");
518
519 let source = SourceRepo::at(temp.path()).expect("source repo");
520 assert!(source.is_dirty().unwrap());
521 }
522
523 #[test]
524 fn source_branch_helpers_read_local_heads_upstream_and_worktrees() {
525 let temp = tempfile::tempdir().expect("tempdir");
526 let sibling = tempfile::tempdir().expect("worktree parent");
527 let feature_worktree = sibling.path().join("feature-worktree");
528 let git = GitInvoker::at(temp.path());
529 git.run_check(["init", "--initial-branch=main"])
530 .expect("init");
531 git.run_check(["config", "user.name", "Test User"])
532 .expect("user name");
533 git.run_check(["config", "user.email", "test@example.com"])
534 .expect("user email");
535 git.run_check(["commit", "--allow-empty", "-m", "initial"])
536 .expect("initial commit");
537 git.run_check(["branch", "feature"])
538 .expect("feature branch");
539 git.run_check(["config", "--local", "branch.main.remote", "origin"])
540 .expect("remote config");
541 git.run_check(["config", "--local", "branch.main.merge", "refs/heads/main"])
542 .expect("merge config");
543 git.run_check([
544 "worktree",
545 "add",
546 feature_worktree.to_str().unwrap(),
547 "feature",
548 ])
549 .expect("add worktree");
550
551 let source = SourceRepo::at(temp.path()).expect("source repo");
552 let main = BranchName::parse("main").unwrap();
553 let feature = BranchName::parse("feature").unwrap();
554
555 assert!(source.branch_exists(&main).unwrap());
556 assert!(
557 !source
558 .branch_exists(&BranchName::parse("missing").unwrap())
559 .unwrap()
560 );
561 assert_eq!(
562 source
563 .upstream_for(&main)
564 .unwrap()
565 .expect("main upstream")
566 .merge_ref
567 .as_str(),
568 "refs/heads/main"
569 );
570 assert_eq!(
571 source.checked_out_worktree_for(&feature).unwrap(),
572 Some(fs::canonicalize(&feature_worktree).unwrap())
573 );
574 let checked_out = source.checked_out_branches().unwrap();
575 assert!(checked_out.iter().any(|branch| branch == &main));
576 assert!(checked_out.iter().any(|branch| branch == &feature));
577 }
578}