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