just playing with tangled
1// Copyright 2020-2023 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::fs;
16use std::io;
17use std::io::Write as _;
18use std::num::NonZeroU32;
19use std::path::Path;
20
21use jj_lib::git;
22use jj_lib::git::GitFetch;
23use jj_lib::ref_name::RefNameBuf;
24use jj_lib::ref_name::RemoteName;
25use jj_lib::ref_name::RemoteNameBuf;
26use jj_lib::repo::Repo as _;
27use jj_lib::str_util::StringPattern;
28use jj_lib::workspace::Workspace;
29
30use super::write_repository_level_trunk_alias;
31use crate::cli_util::CommandHelper;
32use crate::cli_util::WorkspaceCommandHelper;
33use crate::command_error::cli_error;
34use crate::command_error::user_error;
35use crate::command_error::user_error_with_message;
36use crate::command_error::CommandError;
37use crate::commands::git::maybe_add_gitignore;
38use crate::git_util::absolute_git_url;
39use crate::git_util::print_git_import_stats;
40use crate::git_util::with_remote_git_callbacks;
41use crate::ui::Ui;
42
43/// Create a new repo backed by a clone of a Git repo
44///
45/// The Git repo will be a bare git repo stored inside the `.jj/` directory.
46#[derive(clap::Args, Clone, Debug)]
47pub struct GitCloneArgs {
48 /// URL or path of the Git repo to clone
49 ///
50 /// Local path will be resolved to absolute form.
51 #[arg(value_hint = clap::ValueHint::Url)]
52 source: String,
53 /// Specifies the target directory for the Jujutsu repository clone.
54 /// If not provided, defaults to a directory named after the last component
55 /// of the source URL. The full directory path will be created if it
56 /// doesn't exist.
57 #[arg(value_hint = clap::ValueHint::DirPath)]
58 destination: Option<String>,
59 /// Name of the newly created remote
60 #[arg(long = "remote", default_value = "origin")]
61 remote_name: RemoteNameBuf,
62 /// Whether or not to colocate the Jujutsu repo with the git repo
63 #[arg(long)]
64 colocate: bool,
65 /// Create a shallow clone of the given depth
66 #[arg(long)]
67 depth: Option<NonZeroU32>,
68}
69
70fn clone_destination_for_source(source: &str) -> Option<&str> {
71 let destination = source.strip_suffix(".git").unwrap_or(source);
72 let destination = destination.strip_suffix('/').unwrap_or(destination);
73 destination
74 .rsplit_once(&['/', '\\', ':'][..])
75 .map(|(_, name)| name)
76}
77
78fn is_empty_dir(path: &Path) -> bool {
79 if let Ok(mut entries) = path.read_dir() {
80 entries.next().is_none()
81 } else {
82 false
83 }
84}
85
86pub fn cmd_git_clone(
87 ui: &mut Ui,
88 command: &CommandHelper,
89 args: &GitCloneArgs,
90) -> Result<(), CommandError> {
91 let remote_name = &args.remote_name;
92 if command.global_args().at_operation.is_some() {
93 return Err(cli_error("--at-op is not respected"));
94 }
95 let source = absolute_git_url(command.cwd(), &args.source)?;
96 let wc_path_str = args
97 .destination
98 .as_deref()
99 .or_else(|| clone_destination_for_source(&source))
100 .ok_or_else(|| user_error("No destination specified and wasn't able to guess it"))?;
101 let wc_path = command.cwd().join(wc_path_str);
102
103 let wc_path_existed = wc_path.exists();
104 if wc_path_existed && !is_empty_dir(&wc_path) {
105 return Err(user_error(
106 "Destination path exists and is not an empty directory",
107 ));
108 }
109
110 // will create a tree dir in case if was deleted after last check
111 fs::create_dir_all(&wc_path)
112 .map_err(|err| user_error_with_message(format!("Failed to create {wc_path_str}"), err))?;
113
114 // Canonicalize because fs::remove_dir_all() doesn't seem to like e.g.
115 // `/some/path/.`
116 let canonical_wc_path = dunce::canonicalize(&wc_path)
117 .map_err(|err| user_error_with_message(format!("Failed to create {wc_path_str}"), err))?;
118
119 let clone_result = (|| -> Result<_, CommandError> {
120 let workspace_command = init_workspace(ui, command, &canonical_wc_path, args.colocate)?;
121 let mut workspace_command =
122 configure_remote(ui, command, workspace_command, remote_name, &source)?;
123 let default_branch = fetch_new_remote(ui, &mut workspace_command, remote_name, args.depth)?;
124 Ok((workspace_command, default_branch))
125 })();
126 if clone_result.is_err() {
127 let clean_up_dirs = || -> io::Result<()> {
128 fs::remove_dir_all(canonical_wc_path.join(".jj"))?;
129 if args.colocate {
130 fs::remove_dir_all(canonical_wc_path.join(".git"))?;
131 }
132 if !wc_path_existed {
133 fs::remove_dir(&canonical_wc_path)?;
134 }
135 Ok(())
136 };
137 if let Err(err) = clean_up_dirs() {
138 writeln!(
139 ui.warning_default(),
140 "Failed to clean up {}: {}",
141 canonical_wc_path.display(),
142 err
143 )
144 .ok();
145 }
146 }
147
148 let (mut workspace_command, default_branch) = clone_result?;
149 if let Some(name) = &default_branch {
150 let default_symbol = name.to_remote_symbol(remote_name);
151 write_repository_level_trunk_alias(ui, workspace_command.repo_path(), default_symbol)?;
152
153 let default_branch_remote_ref = workspace_command
154 .repo()
155 .view()
156 .get_remote_bookmark(default_symbol);
157 if let Some(commit_id) = default_branch_remote_ref.target.as_normal().cloned() {
158 let mut checkout_tx = workspace_command.start_transaction();
159 // For convenience, create local bookmark as Git would do.
160 checkout_tx.repo_mut().track_remote_bookmark(default_symbol);
161 if let Ok(commit) = checkout_tx.repo().store().get_commit(&commit_id) {
162 checkout_tx.check_out(&commit)?;
163 }
164 checkout_tx.finish(ui, "check out git remote's default branch")?;
165 }
166 }
167 Ok(())
168}
169
170fn init_workspace(
171 ui: &Ui,
172 command: &CommandHelper,
173 wc_path: &Path,
174 colocate: bool,
175) -> Result<WorkspaceCommandHelper, CommandError> {
176 let settings = command.settings_for_new_workspace(wc_path)?;
177 let (workspace, repo) = if colocate {
178 Workspace::init_colocated_git(&settings, wc_path)?
179 } else {
180 Workspace::init_internal_git(&settings, wc_path)?
181 };
182 let workspace_command = command.for_workable_repo(ui, workspace, repo)?;
183 maybe_add_gitignore(&workspace_command)?;
184 Ok(workspace_command)
185}
186
187fn configure_remote(
188 ui: &Ui,
189 command: &CommandHelper,
190 workspace_command: WorkspaceCommandHelper,
191 remote_name: &RemoteName,
192 source: &str,
193) -> Result<WorkspaceCommandHelper, CommandError> {
194 git::add_remote(workspace_command.repo().store(), remote_name, source)?;
195 // Reload workspace to apply new remote configuration to
196 // gix::ThreadSafeRepository behind the store.
197 let workspace = command.load_workspace_at(
198 workspace_command.workspace_root(),
199 workspace_command.settings(),
200 )?;
201 let op = workspace
202 .repo_loader()
203 .load_operation(workspace_command.repo().op_id())?;
204 let repo = workspace.repo_loader().load_at(&op)?;
205 command.for_workable_repo(ui, workspace, repo)
206}
207
208fn fetch_new_remote(
209 ui: &Ui,
210 workspace_command: &mut WorkspaceCommandHelper,
211 remote_name: &RemoteName,
212 depth: Option<NonZeroU32>,
213) -> Result<Option<RefNameBuf>, CommandError> {
214 writeln!(
215 ui.status(),
216 r#"Fetching into new repo in "{}""#,
217 workspace_command.workspace_root().display()
218 )?;
219 let git_settings = workspace_command.settings().git_settings()?;
220 let mut fetch_tx = workspace_command.start_transaction();
221 let mut git_fetch = GitFetch::new(fetch_tx.repo_mut(), &git_settings)?;
222 with_remote_git_callbacks(ui, |cb| {
223 git_fetch.fetch(remote_name, &[StringPattern::everything()], cb, depth)
224 })?;
225 let default_branch =
226 with_remote_git_callbacks(ui, |cb| git_fetch.get_default_branch(remote_name, cb))?;
227 let import_stats = git_fetch.import_refs()?;
228 print_git_import_stats(ui, fetch_tx.repo(), &import_stats, true)?;
229 fetch_tx.finish(ui, "fetch from git remote into empty repo")?;
230 Ok(default_branch)
231}