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::io;
16use std::io::Write as _;
17use std::path::Path;
18use std::path::PathBuf;
19use std::str;
20use std::sync::Arc;
21
22use itertools::Itertools as _;
23use jj_lib::file_util;
24use jj_lib::git;
25use jj_lib::git::parse_git_ref;
26use jj_lib::git::GitRefKind;
27use jj_lib::repo::ReadonlyRepo;
28use jj_lib::repo::Repo as _;
29use jj_lib::view::View;
30use jj_lib::workspace::Workspace;
31
32use super::write_repository_level_trunk_alias;
33use crate::cli_util::start_repo_transaction;
34use crate::cli_util::CommandHelper;
35use crate::cli_util::WorkspaceCommandHelper;
36use crate::command_error::cli_error;
37use crate::command_error::internal_error;
38use crate::command_error::user_error_with_hint;
39use crate::command_error::user_error_with_message;
40use crate::command_error::CommandError;
41use crate::commands::git::maybe_add_gitignore;
42use crate::git_util::is_colocated_git_workspace;
43use crate::git_util::print_git_export_stats;
44use crate::git_util::print_git_import_stats;
45use crate::ui::Ui;
46
47/// Create a new Git backed repo.
48#[derive(clap::Args, Clone, Debug)]
49pub struct GitInitArgs {
50 /// The destination directory where the `jj` repo will be created.
51 /// If the directory does not exist, it will be created.
52 /// If no directory is given, the current directory is used.
53 ///
54 /// By default the `git` repo is under `$destination/.jj`
55 #[arg(default_value = ".", value_hint = clap::ValueHint::DirPath)]
56 destination: String,
57
58 /// Specifies that the `jj` repo should also be a valid
59 /// `git` repo, allowing the use of both `jj` and `git` commands
60 /// in the same directory.
61 ///
62 /// This is done by placing the backing git repo into a `.git` directory in
63 /// the root of the `jj` repo along with the `.jj` directory. If the `.git`
64 /// directory already exists, all the existing commits will be imported.
65 ///
66 /// This option is mutually exclusive with `--git-repo`.
67 #[arg(long, conflicts_with = "git_repo")]
68 colocate: bool,
69
70 /// Specifies a path to an **existing** git repository to be
71 /// used as the backing git repo for the newly created `jj` repo.
72 ///
73 /// If the specified `--git-repo` path happens to be the same as
74 /// the `jj` repo path (both .jj and .git directories are in the
75 /// same working directory), then both `jj` and `git` commands
76 /// will work on the same repo. This is called a co-located repo.
77 ///
78 /// This option is mutually exclusive with `--colocate`.
79 #[arg(long, conflicts_with = "colocate", value_hint = clap::ValueHint::DirPath)]
80 git_repo: Option<String>,
81}
82
83pub fn cmd_git_init(
84 ui: &mut Ui,
85 command: &CommandHelper,
86 args: &GitInitArgs,
87) -> Result<(), CommandError> {
88 if command.global_args().ignore_working_copy {
89 return Err(cli_error("--ignore-working-copy is not respected"));
90 }
91 if command.global_args().at_operation.is_some() {
92 return Err(cli_error("--at-op is not respected"));
93 }
94 let cwd = command.cwd();
95 let wc_path = cwd.join(&args.destination);
96 let wc_path = file_util::create_or_reuse_dir(&wc_path)
97 .and_then(|_| dunce::canonicalize(wc_path))
98 .map_err(|e| user_error_with_message("Failed to create workspace", e))?;
99
100 do_init(
101 ui,
102 command,
103 &wc_path,
104 args.colocate,
105 args.git_repo.as_deref(),
106 )?;
107
108 let relative_wc_path = file_util::relative_path(cwd, &wc_path);
109 writeln!(
110 ui.status(),
111 r#"Initialized repo in "{}""#,
112 relative_wc_path.display()
113 )?;
114
115 Ok(())
116}
117
118fn do_init(
119 ui: &mut Ui,
120 command: &CommandHelper,
121 workspace_root: &Path,
122 colocate: bool,
123 git_repo: Option<&str>,
124) -> Result<(), CommandError> {
125 #[derive(Clone, Debug)]
126 enum GitInitMode {
127 Colocate,
128 External(PathBuf),
129 Internal,
130 }
131
132 let colocated_git_repo_path = workspace_root.join(".git");
133 let init_mode = if colocate {
134 if colocated_git_repo_path.exists() {
135 GitInitMode::External(colocated_git_repo_path)
136 } else {
137 GitInitMode::Colocate
138 }
139 } else if let Some(path_str) = git_repo {
140 let mut git_repo_path = command.cwd().join(path_str);
141 if !git_repo_path.ends_with(".git") {
142 git_repo_path.push(".git");
143 // Undo if .git doesn't exist - likely a bare repo.
144 if !git_repo_path.exists() {
145 git_repo_path.pop();
146 }
147 }
148 GitInitMode::External(git_repo_path)
149 } else {
150 if colocated_git_repo_path.exists() {
151 return Err(user_error_with_hint(
152 "Did not create a jj repo because there is an existing Git repo in this directory.",
153 "To create a repo backed by the existing Git repo, run `jj git init --colocate` \
154 instead.",
155 ));
156 }
157 GitInitMode::Internal
158 };
159
160 let settings = command.settings_for_new_workspace(workspace_root)?;
161 match &init_mode {
162 GitInitMode::Colocate => {
163 let (workspace, repo) = Workspace::init_colocated_git(&settings, workspace_root)?;
164 let workspace_command = command.for_workable_repo(ui, workspace, repo)?;
165 maybe_add_gitignore(&workspace_command)?;
166 }
167 GitInitMode::External(git_repo_path) => {
168 let (workspace, repo) =
169 Workspace::init_external_git(&settings, workspace_root, git_repo_path)?;
170 // Import refs first so all the reachable commits are indexed in
171 // chronological order.
172 let colocated = is_colocated_git_workspace(&workspace, &repo);
173 let repo = init_git_refs(ui, repo, command.string_args(), colocated)?;
174 let mut workspace_command = command.for_workable_repo(ui, workspace, repo)?;
175 maybe_add_gitignore(&workspace_command)?;
176 workspace_command.maybe_snapshot(ui)?;
177 maybe_set_repository_level_trunk_alias(ui, &workspace_command)?;
178 if !workspace_command.working_copy_shared_with_git() {
179 let mut tx = workspace_command.start_transaction();
180 jj_lib::git::import_head(tx.repo_mut())?;
181 if let Some(git_head_id) = tx.repo().view().git_head().as_normal().cloned() {
182 let git_head_commit = tx.repo().store().get_commit(&git_head_id)?;
183 tx.check_out(&git_head_commit)?;
184 }
185 if tx.repo().has_changes() {
186 tx.finish(ui, "import git head")?;
187 }
188 }
189 print_trackable_remote_bookmarks(ui, workspace_command.repo().view())?;
190 }
191 GitInitMode::Internal => {
192 Workspace::init_internal_git(&settings, workspace_root)?;
193 }
194 }
195 Ok(())
196}
197
198/// Imports branches and tags from the underlying Git repo, exports changes if
199/// the repo is colocated.
200///
201/// This is similar to `WorkspaceCommandHelper::import_git_refs()`, but never
202/// moves the Git HEAD to the working copy parent.
203fn init_git_refs(
204 ui: &mut Ui,
205 repo: Arc<ReadonlyRepo>,
206 string_args: &[String],
207 colocated: bool,
208) -> Result<Arc<ReadonlyRepo>, CommandError> {
209 let mut git_settings = repo.settings().git_settings()?;
210 let mut tx = start_repo_transaction(&repo, string_args);
211 // There should be no old refs to abandon, but enforce it.
212 git_settings.abandon_unreachable_commits = false;
213 let stats = git::import_refs(tx.repo_mut(), &git_settings)?;
214 print_git_import_stats(ui, tx.repo(), &stats, false)?;
215 if !tx.repo().has_changes() {
216 return Ok(repo);
217 }
218 if colocated {
219 // If git.auto-local-bookmark = true, local bookmarks could be created for
220 // the imported remote branches.
221 let stats = git::export_refs(tx.repo_mut())?;
222 print_git_export_stats(ui, &stats)?;
223 }
224 let repo = tx.commit("import git refs")?;
225 writeln!(
226 ui.status(),
227 "Done importing changes from the underlying Git repo."
228 )?;
229 Ok(repo)
230}
231
232// Set repository level `trunk()` alias to the default branch for "origin".
233pub fn maybe_set_repository_level_trunk_alias(
234 ui: &Ui,
235 workspace_command: &WorkspaceCommandHelper,
236) -> Result<(), CommandError> {
237 let git_repo = git::get_git_repo(workspace_command.repo().store())?;
238 if let Some(reference) = git_repo
239 .try_find_reference("refs/remotes/origin/HEAD")
240 .map_err(internal_error)?
241 {
242 if let Some(reference_name) = reference.target().try_name() {
243 if let Some((GitRefKind::Bookmark, symbol)) = str::from_utf8(reference_name.as_bstr())
244 .ok()
245 .and_then(|name| parse_git_ref(name.as_ref()))
246 {
247 // TODO: Can we assume the symbolic target points to the same remote?
248 let symbol = symbol.name.to_remote_symbol("origin".as_ref());
249 write_repository_level_trunk_alias(ui, workspace_command.repo_path(), symbol)?;
250 }
251 };
252 };
253
254 Ok(())
255}
256
257fn print_trackable_remote_bookmarks(ui: &Ui, view: &View) -> io::Result<()> {
258 let remote_bookmark_symbols = view
259 .bookmarks()
260 .filter(|(_, bookmark_target)| bookmark_target.local_target.is_present())
261 .flat_map(|(name, bookmark_target)| {
262 bookmark_target
263 .remote_refs
264 .into_iter()
265 .filter(|&(_, remote_ref)| !remote_ref.is_tracked())
266 .map(move |(remote, _)| name.to_remote_symbol(remote))
267 })
268 .collect_vec();
269 if remote_bookmark_symbols.is_empty() {
270 return Ok(());
271 }
272
273 if let Some(mut formatter) = ui.status_formatter() {
274 writeln!(
275 formatter.labeled("hint").with_heading("Hint: "),
276 "The following remote bookmarks aren't associated with the existing local bookmarks:"
277 )?;
278 for symbol in &remote_bookmark_symbols {
279 write!(formatter, " ")?;
280 writeln!(formatter.labeled("bookmark"), "{symbol}")?;
281 }
282 writeln!(
283 formatter.labeled("hint").with_heading("Hint: "),
284 "Run `jj bookmark track {syms}` to keep local bookmarks updated on future pulls.",
285 syms = remote_bookmark_symbols.iter().join(" "),
286 )?;
287 }
288 Ok(())
289}