Skip to main content

outpost_core/
source_repo.rs

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}