Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/commands/up.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,9 @@ struct CreateProjectReq {
run_command: Option<String>,
paper_id: Option<String>,
clone_url: Option<String>,
/// Fork a public GitHub repo under the authenticated account, then clone the
/// fork into the project. Takes precedence over `clone_url`.
fork_url: Option<String>,
#[serde(default)]
create_folder: bool,
#[serde(default)]
Expand Down Expand Up @@ -1128,7 +1131,24 @@ async fn create_project(
let create_folder = req.create_folder;
let require_new_folder = req.require_new_folder;
let initialize_git = req.initialize_git;
let fork_url = req.fork_url.filter(|url| !url.trim().is_empty());
let clone_url = req.clone_url.filter(|url| !url.trim().is_empty());
// Forking a public GitHub repo takes priority: create the fork under the
// authenticated account and clone that fork, keeping the original as
// `upstream` so experiment branches push to the user's own fork.
let (clone_url, upstream_url) = match fork_url {
Some(url) => {
let (fork_owner, fork_repo) = local::github::fork_public_repo(&url)
.await
.map_err(bad_request)?;
let upstream = local::github::canonical_repo_url(&url);
(
Some(format!("https://github.com/{fork_owner}/{fork_repo}")),
upstream,
)
}
None => (clone_url, None),
};
let paper_id = req
.paper_id
.map(|paper_id| paper_id.trim().to_string())
Expand Down Expand Up @@ -1164,6 +1184,7 @@ async fn create_project(
require_new_folder,
initialize_git,
clone_url,
upstream_url,
shallow_clone,
run_command,
paper_id,
Expand Down
10 changes: 10 additions & 0 deletions src/local/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,16 @@ pub fn rename_origin_to_upstream(path: &Path) -> Result<()> {
Ok(())
}

/// Set the URL of a remote, adding it if it does not yet exist.
pub fn set_remote_url(path: &Path, name: &str, url: &str) -> Result<()> {
if git(Some(path), &["remote", "get-url", name]).is_ok() {
git(Some(path), &["remote", "set-url", name, url])?;
} else {
git(Some(path), &["remote", "add", name, url])?;
}
Ok(())
}

pub fn require_current_branch(path: &Path) -> Result<String> {
let branch = git(Some(path), &["symbolic-ref", "--quiet", "--short", "HEAD"])
.map_err(|_| anyhow!("The repository is on a detached HEAD. Check out a branch first."))?;
Expand Down
40 changes: 40 additions & 0 deletions src/local/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,29 @@ pub async fn public_repo_size_kb(url: &str) -> Option<u64> {
body.get("size").and_then(Value::as_u64)
}

/// Fork a public GitHub repository under the currently authenticated account
/// without cloning it locally or adding a remote. Returns the fork's
/// `(owner, repo)`.
pub async fn fork_public_repo(url: &str) -> Result<(String, String)> {
let (owner, repo) = super::git::github_repository(url)
.ok_or_else(|| anyhow!("Expected a github.com URL like https://github.com/owner/repo."))?;
// `gh repo fork` is idempotent: an existing fork is reused rather than
// duplicated, so re-running against the same repo is safe.
gh(
&["repo", "fork", &format!("{owner}/{repo}")],
Duration::from_secs(120),
)
.await?;
let login = viewer_login().await?;
Ok((login, repo))
}

/// Canonical `https://github.com/{owner}/{repo}` URL for an input repo URL.
pub fn canonical_repo_url(url: &str) -> Option<String> {
let (owner, repo) = super::git::github_repository(url)?;
Some(format!("https://github.com/{owner}/{repo}"))
}

pub struct RepoMeta {
pub can_push: bool,
pub archived: bool,
Expand Down Expand Up @@ -242,6 +265,23 @@ mod tests {
assert!(!meta.archived);
}

#[test]
fn canonical_repo_url_normalizes_github_urls() {
assert_eq!(
canonical_repo_url("https://github.com/owner/repo.git"),
Some("https://github.com/owner/repo".to_string())
);
assert_eq!(
canonical_repo_url("git@github.com:owner/repo.git"),
Some("https://github.com/owner/repo".to_string())
);
assert_eq!(
canonical_repo_url("https://github.com/owner/repo/"),
Some("https://github.com/owner/repo".to_string())
);
assert_eq!(canonical_repo_url("not a github url"), None);
}

#[test]
fn github_api_errors_preserve_missing_and_collision_signals() {
assert!(github_api_not_found("gh: Not Found (HTTP 404)"));
Expand Down
66 changes: 60 additions & 6 deletions src/local/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,19 @@ pub(crate) fn expand_path(path: &str) -> Result<PathBuf> {

const PAPER_PDF_NAME: &str = "paper.pdf";

/// A repository to clone into the project, with an optional upstream to record
/// alongside it (used when the clone is a fork under the authenticated account).
struct CloneSpec<'a> {
url: &'a str,
upstream: Option<&'a str>,
}

fn prepare_path(
path: &str,
create_folder: bool,
require_new_folder: bool,
initialize_git: bool,
clone_url: Option<&str>,
clone: Option<CloneSpec>,
shallow_clone: bool,
paper_pdf: Option<&[u8]>,
) -> Result<PathBuf> {
Expand All @@ -84,20 +91,26 @@ fn prepare_path(
}
}
}
if let Some(url) = clone_url.map(str::trim).filter(|url| !url.is_empty()) {
if let Some(spec) = clone.filter(|spec| !spec.url.trim().is_empty()) {
if path.exists() {
let mut entries = std::fs::read_dir(&path)?;
if entries.next().is_some() {
return Err(crate::error::anyhow!(
"{} must be empty before cloning the paper repository",
"{} must be empty before cloning the repository",
path.display()
));
}
} else if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
git::clone_public(url, &path, shallow_clone)?;
git::rename_origin_to_upstream(&path)?;
git::clone_public(spec.url, &path, shallow_clone)?;
if let Some(upstream) = spec.upstream.map(str::trim).filter(|url| !url.is_empty()) {
// Cloned a fork under the authenticated account: keep `origin`
// pointing at the fork and record the original as `upstream`.
git::set_remote_url(&path, "upstream", upstream)?;
} else {
git::rename_origin_to_upstream(&path)?;
}
} else if require_new_folder && path.exists() {
return Err(crate::error::anyhow!(
"{} already exists; choose a new folder for a blank project",
Expand Down Expand Up @@ -181,18 +194,23 @@ pub fn create_project(
require_new_folder,
initialize_git,
clone_url,
upstream_url,
shallow_clone,
run_command,
paper_id,
paper_pdf,
} = options;
let slug = unique_project_slug(store, &slugify(name))?;
let clone = clone_url.as_deref().map(|url| CloneSpec {
url,
upstream: upstream_url.as_deref(),
});
let repo_path = prepare_path(
path,
create_folder,
require_new_folder,
initialize_git,
clone_url.as_deref(),
clone,
shallow_clone,
paper_pdf.as_deref(),
)?;
Expand Down Expand Up @@ -235,6 +253,7 @@ pub struct CreateProjectOptions {
pub require_new_folder: bool,
pub initialize_git: bool,
pub clone_url: Option<String>,
pub upstream_url: Option<String>,
pub shallow_clone: bool,
pub run_command: Option<String>,
pub paper_id: Option<String>,
Expand Down Expand Up @@ -1058,6 +1077,41 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}

#[test]
fn fork_clone_keeps_origin_and_records_upstream() {
let root = root();
let source = root.join("source");
initialized(&source);
let store = Store::open_at(root.join("data")).unwrap();
let destination = root.join("fork");
let project = create_project(
&store,
"Fork",
destination.to_str().unwrap(),
CreateProjectOptions {
create_folder: true,
clone_url: Some(source.to_string_lossy().into_owned()),
upstream_url: Some("https://github.com/owner/original".to_string()),
..Default::default()
},
)
.unwrap();
let remotes = git::remotes(Path::new(&project.repo_path)).unwrap();
assert_eq!(remotes.len(), 2);
let names: Vec<&str> = remotes.iter().map(|(name, _)| name.as_str()).collect();
assert!(names.contains(&"origin"));
assert!(names.contains(&"upstream"));
assert_eq!(
remotes
.iter()
.find(|(name, _)| name == "upstream")
.unwrap()
.1,
"https://github.com/owner/original"
);
std::fs::remove_dir_all(root).unwrap();
}

#[test]
fn ordinary_github_origin_remains_opt_in() {
let root = root();
Expand Down
Loading