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