Skip to main content

outpost_core/ops/
add.rs

1use std::ffi::OsString;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use crate::metadata::Metadata;
6use crate::registry::RegistryEntry;
7use crate::safety;
8use crate::{
9    BranchName, Outpost, OutpostError, OutpostResult, RemoteName, Reporter, SourceRepo, StepKind,
10};
11
12pub enum AddCheckout {
13    CheckoutExisting {
14        target_branch: Option<BranchName>,
15    },
16    NewBranch {
17        name: BranchName,
18        target_branch: Option<BranchName>,
19    },
20}
21
22pub struct AddOptions {
23    pub destination: PathBuf,
24    pub checkout: AddCheckout,
25    pub remote_name: RemoteName,
26}
27
28pub fn run(
29    source: &SourceRepo,
30    opts: AddOptions,
31    reporter: &mut dyn Reporter,
32) -> OutpostResult<Outpost> {
33    let AddOptions {
34        destination,
35        checkout,
36        remote_name,
37    } = opts;
38    let destination = resolve_destination(source, &destination)?;
39    check_destination_clean(&destination)?;
40
41    let branch = resolve_existing_branch(source, &checkout)?;
42
43    source.git().run_check([
44        OsString::from("-c"),
45        OsString::from("protocol.file.allow=user"),
46        OsString::from("clone"),
47        OsString::from("--no-shared"),
48        OsString::from("--"),
49        source.work_tree().as_os_str().to_os_string(),
50        destination.as_os_str().to_os_string(),
51    ])?;
52
53    let outpost_git = crate::source_repo::invoker_at(&destination, source.env());
54    if remote_name.as_str() != "origin" {
55        outpost_git.run_check(["remote", "rename", "origin", remote_name.as_str()])?;
56    }
57    apply_checkout(source, &outpost_git, &checkout, &branch, &remote_name)?;
58    Metadata {
59        source_repo: source.work_tree().to_path_buf(),
60        remote_name: remote_name.clone(),
61    }
62    .write(&outpost_git)?;
63
64    reporter.step(
65        StepKind::ConfigChange,
66        &format!(
67            "configuring source {}: receive.denyCurrentBranch=updateInstead",
68            source.work_tree().display()
69        ),
70    );
71    source.git().run_check([
72        "config",
73        "--local",
74        "receive.denyCurrentBranch",
75        "updateInstead",
76    ])?;
77
78    let mut registry = source.registry_mut()?;
79    registry.add(RegistryEntry::new(destination.clone(), remote_name)?)?;
80    registry.save()?;
81
82    source.outpost_at(&destination)
83}
84
85fn resolve_destination(source: &SourceRepo, destination: &Path) -> OutpostResult<PathBuf> {
86    let anchored = if destination.is_absolute() {
87        destination.to_path_buf()
88    } else {
89        source.work_tree().join(destination)
90    };
91    let (parent, name) = destination_parent_and_name(&anchored)?;
92    let parent = std::fs::canonicalize(&parent).map_err(|source| OutpostError::IoAt {
93        path: parent.clone(),
94        source,
95    })?;
96
97    Ok(parent.join(name))
98}
99
100fn resolve_existing_branch(
101    source: &SourceRepo,
102    checkout: &AddCheckout,
103) -> OutpostResult<BranchName> {
104    match checkout {
105        AddCheckout::CheckoutExisting { target_branch } => {
106            resolve_target_branch(source, target_branch)
107        }
108        AddCheckout::NewBranch { target_branch, .. } => {
109            resolve_target_branch(source, target_branch)
110        }
111    }
112}
113
114fn resolve_target_branch(
115    source: &SourceRepo,
116    target_branch: &Option<BranchName>,
117) -> OutpostResult<BranchName> {
118    match target_branch {
119        Some(branch) => {
120            require_branch_exists(source, branch)?;
121            Ok(branch.clone())
122        }
123        None => {
124            let branch = source.current_branch()?;
125            if source.branch_exists(&branch)? {
126                Ok(branch)
127            } else {
128                Err(OutpostError::BranchNotFound {
129                    branch: "HEAD".to_owned(),
130                    repo: source.work_tree().to_path_buf(),
131                })
132            }
133        }
134    }
135}
136
137fn require_branch_exists(source: &SourceRepo, branch: &BranchName) -> OutpostResult<()> {
138    if source.branch_exists(branch)? {
139        Ok(())
140    } else {
141        Err(OutpostError::BranchNotFound {
142            branch: branch.as_str().to_owned(),
143            repo: source.work_tree().to_path_buf(),
144        })
145    }
146}
147
148fn check_destination_clean(destination: &Path) -> OutpostResult<()> {
149    let (parent, name) = destination_parent_and_name(destination)?;
150    safety::check_destination_clean(&parent, &name).map_err(|err| match err {
151        OutpostError::DestinationExists(_) => {
152            OutpostError::DestinationExists(destination.to_path_buf())
153        }
154        OutpostError::DestinationInsideRepo(_) => {
155            OutpostError::DestinationInsideRepo(destination.to_path_buf())
156        }
157        other => other,
158    })
159}
160
161fn destination_parent_and_name(destination: &Path) -> OutpostResult<(PathBuf, PathBuf)> {
162    let parent = destination
163        .parent()
164        .filter(|path| !path.as_os_str().is_empty())
165        .map(PathBuf::from)
166        .unwrap_or_else(|| PathBuf::from("."));
167    let name = destination.file_name().ok_or_else(|| OutpostError::IoAt {
168        path: destination.to_path_buf(),
169        source: io::Error::new(
170            io::ErrorKind::InvalidInput,
171            "destination path has no file name",
172        ),
173    })?;
174
175    Ok((parent, PathBuf::from(name)))
176}
177
178fn apply_checkout(
179    source: &SourceRepo,
180    git: &crate::GitInvoker,
181    checkout: &AddCheckout,
182    target_branch: &BranchName,
183    remote_name: &RemoteName,
184) -> OutpostResult<()> {
185    match checkout {
186        AddCheckout::CheckoutExisting { .. } => git.run_check(["switch", target_branch.as_str()]),
187        AddCheckout::NewBranch { name, .. } => {
188            source
189                .git()
190                .run_check(["branch", name.as_str(), target_branch.as_str()])?;
191            let remote_tracking_ref =
192                format!("refs/remotes/{}/{}", remote_name.as_str(), name.as_str());
193            let fetch_refspec = format!("{}:{remote_tracking_ref}", name.as_str());
194            let remote_branch = format!("{}/{}", remote_name.as_str(), name.as_str());
195            git.run_check(["fetch", remote_name.as_str(), &fetch_refspec])?;
196            git.run_check(["switch", "--track", &remote_branch])
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn destination_parent_and_name_splits_bare_relative_path() {
207        let (parent, name) =
208            destination_parent_and_name(Path::new("outpost")).expect("split destination");
209
210        assert_eq!(parent, PathBuf::from("."));
211        assert_eq!(name, PathBuf::from("outpost"));
212    }
213
214    #[test]
215    fn destination_parent_and_name_splits_nested_relative_path() {
216        let (parent, name) =
217            destination_parent_and_name(Path::new("nested/outpost")).expect("split destination");
218
219        assert_eq!(parent, PathBuf::from("nested"));
220        assert_eq!(name, PathBuf::from("outpost"));
221    }
222}