mirror of
https://github.com/jj-vcs/jj.git
synced 2026-07-03 14:02:54 +08:00
cli: add jj submodule bind
This commit is contained in:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -1819,13 +1819,16 @@ version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b47c88884dd3c1a19a39da19d10211fcdea2809aadc86869b6e824a1774340f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bstr",
|
||||
"gix-commitgraph",
|
||||
"gix-date",
|
||||
"gix-error",
|
||||
"gix-hash",
|
||||
"gix-hashtable",
|
||||
"gix-object",
|
||||
"gix-revwalk",
|
||||
"gix-trace",
|
||||
"nonempty",
|
||||
]
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ gix = { version = "0.84.0", default-features = false, features = [
|
||||
"blob-diff",
|
||||
"index",
|
||||
"max-performance-safe",
|
||||
"revision",
|
||||
"sha1",
|
||||
"zlib-rs",
|
||||
] }
|
||||
|
||||
@@ -542,6 +542,36 @@ impl CommandHelper {
|
||||
Ok(factory)
|
||||
}
|
||||
|
||||
/// Loads helper for the workspace rooted at the given path at its head
|
||||
/// operation, but never snapshots its working copy.
|
||||
#[instrument(skip(self, ui))]
|
||||
pub async fn workspace_helper_at_head_no_snapshot(
|
||||
&self,
|
||||
ui: &Ui,
|
||||
workspace_root: &Path,
|
||||
) -> Result<WorkspaceCommandHelper, CommandError> {
|
||||
let loader = self.new_workspace_loader_at(workspace_root)?;
|
||||
let mut config_env = self.data.config_env.clone();
|
||||
let mut raw_config = self.data.raw_config.clone();
|
||||
config_env.reset_repo_path(loader.repo_path());
|
||||
config_env.reload_repo_config(ui, &mut raw_config)?;
|
||||
config_env.reset_workspace_path(loader.workspace_root());
|
||||
config_env.reload_workspace_config(ui, &mut raw_config)?;
|
||||
let mut config = config_env.resolve_config(&raw_config)?;
|
||||
jj_lib::config::migrate(&mut config, &self.data.config_migrations)?;
|
||||
let settings = self.data.settings.with_new_config(config)?;
|
||||
let workspace = loader
|
||||
.load(
|
||||
&settings,
|
||||
&self.data.store_factories,
|
||||
&self.data.working_copy_factories,
|
||||
)
|
||||
.map_err(|err| map_workspace_load_error(err, None))?;
|
||||
let repo = workspace.repo_loader().load_at_head().await?;
|
||||
let env = self.workspace_environment(ui, &workspace)?;
|
||||
WorkspaceCommandHelper::new(ui, workspace, repo, env, true)
|
||||
}
|
||||
|
||||
/// Loads workspace for the current command.
|
||||
#[instrument(skip_all)]
|
||||
pub fn load_workspace(&self) -> Result<Workspace, CommandError> {
|
||||
|
||||
@@ -57,6 +57,8 @@ mod sparse;
|
||||
mod split;
|
||||
mod squash;
|
||||
mod status;
|
||||
#[cfg(feature = "git")]
|
||||
mod submodule;
|
||||
mod tag;
|
||||
mod undo;
|
||||
mod unsign;
|
||||
@@ -149,6 +151,9 @@ enum Command {
|
||||
Split(split::SplitArgs),
|
||||
Squash(squash::SquashArgs),
|
||||
Status(status::StatusArgs),
|
||||
#[cfg(feature = "git")]
|
||||
#[command(subcommand)]
|
||||
Submodule(submodule::SubmoduleCommand),
|
||||
#[command(subcommand)]
|
||||
Tag(tag::TagCommand),
|
||||
Undo(undo::UndoArgs),
|
||||
@@ -215,6 +220,8 @@ pub async fn run_command(ui: &mut Ui, command_helper: &CommandHelper) -> Result<
|
||||
Command::Split(args) => split::cmd_split(ui, command_helper, args).await,
|
||||
Command::Squash(args) => squash::cmd_squash(ui, command_helper, args).await,
|
||||
Command::Status(args) => status::cmd_status(ui, command_helper, args).await,
|
||||
#[cfg(feature = "git")]
|
||||
Command::Submodule(args) => submodule::cmd_submodule(ui, command_helper, args).await,
|
||||
Command::Tag(args) => tag::cmd_tag(ui, command_helper, args).await,
|
||||
Command::Undo(args) => undo::cmd_undo(ui, command_helper, args).await,
|
||||
Command::Unsign(args) => unsign::cmd_unsign(ui, command_helper, args).await,
|
||||
|
||||
227
cli/src/commands/submodule/mod.rs
Normal file
227
cli/src/commands/submodule/mod.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
// Copyright 2026 The Jujutsu Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::path::Path;
|
||||
|
||||
use clap_complete::ArgValueCompleter;
|
||||
use jj_lib::backend::TreeValue;
|
||||
use jj_lib::merge::Merge;
|
||||
use jj_lib::merged_tree_builder::MergedTreeBuilder;
|
||||
use jj_lib::object_id::ObjectId as _;
|
||||
use jj_lib::repo::Repo as _;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::cli_util::CommandHelper;
|
||||
use crate::cli_util::RevisionArg;
|
||||
use crate::command_error::CommandError;
|
||||
use crate::command_error::internal_error;
|
||||
use crate::command_error::user_error;
|
||||
use crate::command_error::user_error_with_message;
|
||||
use crate::complete;
|
||||
use crate::ui::Ui;
|
||||
|
||||
/// Manage Git submodules.
|
||||
#[derive(clap::Subcommand, Clone, Debug)]
|
||||
pub enum SubmoduleCommand {
|
||||
Bind(SubmoduleBindArgs),
|
||||
}
|
||||
|
||||
pub async fn cmd_submodule(
|
||||
ui: &mut Ui,
|
||||
command: &CommandHelper,
|
||||
subcommand: &SubmoduleCommand,
|
||||
) -> Result<(), CommandError> {
|
||||
match subcommand {
|
||||
SubmoduleCommand::Bind(args) => cmd_submodule_bind(ui, command, args).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly update a submodule gitlink.
|
||||
///
|
||||
/// The submodule revision is resolved in the submodule repository at PATH. The
|
||||
/// selected superproject revision is then rewritten so PATH stores that commit
|
||||
/// as a `160000` Git submodule tree entry. The superproject working-copy
|
||||
/// snapshot does not update submodule gitlinks automatically.
|
||||
#[derive(clap::Args, Clone, Debug)]
|
||||
pub struct SubmoduleBindArgs {
|
||||
/// Submodule path to update
|
||||
#[arg(value_name = "PATH", value_hint = clap::ValueHint::DirPath)]
|
||||
#[arg(add = ArgValueCompleter::new(complete::submodule_paths))]
|
||||
path: String,
|
||||
|
||||
/// Revision to resolve in the submodule repository
|
||||
#[arg(long, short, value_name = "SUBMODULE_REV")]
|
||||
#[arg(add = ArgValueCompleter::new(complete::submodule_revision))]
|
||||
revision: String,
|
||||
|
||||
/// Superproject revision to rewrite
|
||||
#[arg(long, value_name = "REVSET")]
|
||||
#[arg(add = ArgValueCompleter::new(complete::revset_expression_mutable))]
|
||||
change: Option<RevisionArg>,
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn cmd_submodule_bind(
|
||||
ui: &mut Ui,
|
||||
command: &CommandHelper,
|
||||
args: &SubmoduleBindArgs,
|
||||
) -> Result<(), CommandError> {
|
||||
let mut workspace_command = command.workspace_helper(ui).await?;
|
||||
jj_lib::git::get_git_backend(workspace_command.repo().store())?;
|
||||
|
||||
let path = workspace_command.parse_file_path(&args.path)?;
|
||||
if path.is_root() {
|
||||
return Err(user_error("Cannot bind the root path as a submodule"));
|
||||
}
|
||||
let ui_path = workspace_command.format_file_path(&path);
|
||||
|
||||
let target_superproject_commit = workspace_command
|
||||
.resolve_single_rev(ui, args.change.as_ref().unwrap_or(&RevisionArg::AT))
|
||||
.await?;
|
||||
workspace_command
|
||||
.check_rewritable([target_superproject_commit.id()])
|
||||
.await?;
|
||||
|
||||
let old_value = target_superproject_commit.tree().path_value(&path).await?;
|
||||
let Some(old_tree_value) = old_value.as_resolved().and_then(Option::as_ref) else {
|
||||
return Err(
|
||||
user_error(format!("Submodule path {ui_path} is conflicted or absent"))
|
||||
.hinted("Only existing Git submodule gitlinks can be updated for now."),
|
||||
);
|
||||
};
|
||||
let TreeValue::GitSubmodule(old_commit_id) = old_tree_value else {
|
||||
return Err(user_error(format!("Path {ui_path} is not a Git submodule")));
|
||||
};
|
||||
let submodule_fs_path = path
|
||||
.to_fs_path(workspace_command.workspace_root())
|
||||
.map_err(internal_error)?;
|
||||
let target_commit_id =
|
||||
resolve_submodule_revision(ui, command, &submodule_fs_path, &args.revision, &ui_path)
|
||||
.await?;
|
||||
if old_commit_id == &target_commit_id {
|
||||
writeln!(ui.status(), "Nothing changed.")?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut tree_builder = MergedTreeBuilder::new(target_superproject_commit.tree());
|
||||
tree_builder.set_or_remove(
|
||||
path.clone(),
|
||||
Merge::normal(TreeValue::GitSubmodule(target_commit_id.clone())),
|
||||
);
|
||||
let new_tree = tree_builder.write_tree().await?;
|
||||
|
||||
let mut tx = workspace_command.start_transaction();
|
||||
let new_commit = tx
|
||||
.repo_mut()
|
||||
.rewrite_commit(&target_superproject_commit)
|
||||
.set_tree(new_tree)
|
||||
.write()
|
||||
.await?;
|
||||
let num_rebased = tx.repo_mut().rebase_descendants().await?;
|
||||
if let Some(mut formatter) = ui.status_formatter() {
|
||||
writeln!(formatter, "Updated Git submodule {ui_path}:")?;
|
||||
writeln!(formatter, " old: {old_commit_id}")?;
|
||||
writeln!(formatter, " new: {target_commit_id}")?;
|
||||
write!(formatter, "Rewritten superproject commit: ")?;
|
||||
tx.write_commit_summary(formatter.as_mut(), &new_commit)?;
|
||||
writeln!(formatter)?;
|
||||
if num_rebased > 0 {
|
||||
writeln!(formatter, "Rebased {num_rebased} descendant commits")?;
|
||||
}
|
||||
}
|
||||
tx.finish(
|
||||
ui,
|
||||
format!("update git submodule {ui_path} to {target_commit_id}"),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_submodule_revision(
|
||||
ui: &Ui,
|
||||
command: &CommandHelper,
|
||||
submodule_path: &Path,
|
||||
revision: &str,
|
||||
ui_path: &str,
|
||||
) -> Result<jj_lib::backend::CommitId, CommandError> {
|
||||
if submodule_path.join(".jj").exists() {
|
||||
resolve_submodule_jj_revision(ui, command, submodule_path, revision).await
|
||||
} else {
|
||||
resolve_submodule_git_revision(submodule_path, revision, ui_path)
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_submodule_jj_revision(
|
||||
ui: &Ui,
|
||||
command: &CommandHelper,
|
||||
submodule_path: &Path,
|
||||
revision: &str,
|
||||
) -> Result<jj_lib::backend::CommitId, CommandError> {
|
||||
let submodule_workspace_command = command
|
||||
.workspace_helper_at_head_no_snapshot(ui, submodule_path)
|
||||
.await?;
|
||||
let commit = submodule_workspace_command
|
||||
.resolve_single_rev(ui, &RevisionArg::from(revision.to_owned()))
|
||||
.await?;
|
||||
parse_git_submodule_commit_id(&commit.id().hex(), revision)
|
||||
}
|
||||
|
||||
fn resolve_submodule_git_revision(
|
||||
submodule_path: &Path,
|
||||
revision: &str,
|
||||
ui_path: &str,
|
||||
) -> Result<jj_lib::backend::CommitId, CommandError> {
|
||||
let git_repo = gix::open(submodule_path).map_err(|err| {
|
||||
user_error_with_message(format!("Failed to open Git submodule at {ui_path}"), err)
|
||||
})?;
|
||||
let id = git_repo
|
||||
.rev_parse_single(revision)
|
||||
.map_err(|err| revision_resolution_error("gix", revision, ui_path, err))?;
|
||||
let object = id
|
||||
.object()
|
||||
.map_err(|err| revision_resolution_error("gix", revision, ui_path, err))?;
|
||||
let commit = object
|
||||
.peel_to_commit()
|
||||
.map_err(|err| revision_resolution_error("gix", revision, ui_path, err))?;
|
||||
parse_git_submodule_commit_id(&commit.id.to_string(), revision)
|
||||
}
|
||||
|
||||
fn parse_git_submodule_commit_id(
|
||||
hex: &str,
|
||||
revision: &str,
|
||||
) -> Result<jj_lib::backend::CommitId, CommandError> {
|
||||
if hex.len() != 40 {
|
||||
return Err(user_error(format!(
|
||||
"Only SHA-1 Git submodule commits are supported, but {revision:?} resolved to {hex:?}"
|
||||
)));
|
||||
}
|
||||
jj_lib::backend::CommitId::try_from_hex(hex).ok_or_else(|| {
|
||||
user_error(format!(
|
||||
"Resolved invalid commit id {hex:?} for submodule revision {revision:?}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn revision_resolution_error(
|
||||
tool: &str,
|
||||
revision: &str,
|
||||
ui_path: &str,
|
||||
err: impl std::fmt::Display,
|
||||
) -> CommandError {
|
||||
user_error(format!(
|
||||
"Failed to resolve submodule revision {revision:?} at {ui_path} with {tool}"
|
||||
))
|
||||
.hinted(err.to_string())
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
use std::collections::HashSet;
|
||||
use std::io::BufRead as _;
|
||||
use std::path::Path;
|
||||
#[cfg(feature = "git")]
|
||||
use std::process::Command;
|
||||
|
||||
use clap::FromArgMatches as _;
|
||||
use clap::builder::StyledStr;
|
||||
@@ -1010,6 +1012,163 @@ pub fn all_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate>
|
||||
all_files_from_rev(parse::revision_or_wc(), current)
|
||||
}
|
||||
|
||||
pub fn submodule_paths(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
|
||||
let Some(current) = current.to_str() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let normalized_prefix = normalize_path(Path::new(current));
|
||||
let normalized_prefix = slash_path(&normalized_prefix);
|
||||
|
||||
with_jj(|jj, _| {
|
||||
let output = jj
|
||||
.build()
|
||||
.arg("file")
|
||||
.arg("list")
|
||||
.arg("--revision")
|
||||
.arg("@")
|
||||
.arg("--template")
|
||||
.arg(r#"if(file_type == "git-submodule", path.display() ++ "\n")"#)
|
||||
.arg(current_prefix_to_fileset(current))
|
||||
.output()
|
||||
.map_err(user_error)?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.filter_map(|path| {
|
||||
path_completion_candidate_from(
|
||||
current,
|
||||
&normalized_prefix,
|
||||
Path::new(path),
|
||||
Some("Git submodule".into()),
|
||||
)
|
||||
})
|
||||
.dedup()
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "git")]
|
||||
pub fn submodule_revision(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
|
||||
let Some(submodule_path) = parse::submodule_bind_path().and_then(|path| {
|
||||
let path = Path::new(&path);
|
||||
if path.is_absolute() {
|
||||
Some(path.to_owned())
|
||||
} else {
|
||||
std::env::current_dir().ok().map(|cwd| cwd.join(path))
|
||||
}
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !submodule_path.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let result = if submodule_path.join(".jj").exists() {
|
||||
submodule_jj_revisions(&submodule_path, current)
|
||||
} else {
|
||||
submodule_git_refs(&submodule_path, current)
|
||||
};
|
||||
result.unwrap_or_else(|err| {
|
||||
eprintln!("{}", err.error);
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "git")]
|
||||
fn submodule_jj_revisions(
|
||||
submodule_path: &Path,
|
||||
current: &std::ffi::OsStr,
|
||||
) -> Result<Vec<CompletionCandidate>, CommandError> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct MachineCompletionCandidate {
|
||||
value: String,
|
||||
help: Option<String>,
|
||||
id: Option<String>,
|
||||
tag: Option<String>,
|
||||
display_order: Option<usize>,
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
let current_exe = std::env::current_exe().map_err(user_error)?;
|
||||
let output = Command::new(current_exe)
|
||||
.current_dir(submodule_path)
|
||||
.env_remove("COMPLETE")
|
||||
.env_remove("_CLAP_COMPLETE_INDEX")
|
||||
.args(["--ignore-working-copy", "--color=never", "--no-pager"])
|
||||
.args(["util", "complete", "--index", "3", "--"])
|
||||
.args(["jj", "log", "-r"])
|
||||
.arg(current)
|
||||
.output()
|
||||
.map_err(user_error)?;
|
||||
if !output.status.success() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let candidates: Vec<MachineCompletionCandidate> =
|
||||
serde_json::from_slice(&output.stdout).map_err(user_error)?;
|
||||
Ok(candidates
|
||||
.into_iter()
|
||||
.map(|candidate| {
|
||||
CompletionCandidate::new(candidate.value)
|
||||
.help(candidate.help.map(Into::into))
|
||||
.id(candidate.id)
|
||||
.tag(candidate.tag.map(Into::into))
|
||||
.display_order(candidate.display_order)
|
||||
.hide(candidate.hidden)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(feature = "git")]
|
||||
fn submodule_git_refs(
|
||||
submodule_path: &Path,
|
||||
current: &std::ffi::OsStr,
|
||||
) -> Result<Vec<CompletionCandidate>, CommandError> {
|
||||
let Some(current) = current.to_str() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let git_repo = gix::open(submodule_path).map_err(user_error)?;
|
||||
let refs = git_repo.references().map_err(user_error)?;
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut candidates = Vec::new();
|
||||
if "HEAD".starts_with(current) {
|
||||
seen.insert(std::ffi::OsString::from("HEAD"));
|
||||
candidates.push(
|
||||
CompletionCandidate::new("HEAD")
|
||||
.help(git_repo.head_id().ok().map(|id| id.to_string().into()))
|
||||
.tag(Some("Git ref".into())),
|
||||
);
|
||||
}
|
||||
|
||||
for (prefix, iter) in [
|
||||
("refs/heads/", refs.local_branches().map_err(user_error)?),
|
||||
("refs/tags/", refs.tags().map_err(user_error)?),
|
||||
("refs/remotes/", refs.remote_branches().map_err(user_error)?),
|
||||
] {
|
||||
for reference in iter.filter_map(Result::ok) {
|
||||
let full_name = reference.name().as_bstr().to_string();
|
||||
let Some(name) = full_name.strip_prefix(prefix) else {
|
||||
continue;
|
||||
};
|
||||
if !name.starts_with(current) {
|
||||
continue;
|
||||
}
|
||||
let value = std::ffi::OsString::from(name);
|
||||
if seen.insert(value.clone()) {
|
||||
candidates.push(
|
||||
CompletionCandidate::new(value)
|
||||
.help(reference.try_id().map(|id| id.to_string().into()))
|
||||
.tag(Some("Git ref".into())),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
pub fn modified_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
|
||||
modified_files_from_rev((parse::revision_or_wc(), None), current)
|
||||
}
|
||||
@@ -1328,6 +1487,35 @@ mod parse {
|
||||
parse_flag(candidates, std::env::args()).collect()
|
||||
}
|
||||
|
||||
#[cfg(feature = "git")]
|
||||
pub fn submodule_bind_path() -> Option<String> {
|
||||
let args = std::env::args().collect::<Vec<_>>();
|
||||
let bind_index = args
|
||||
.windows(2)
|
||||
.rposition(|window| window == ["submodule", "bind"])?;
|
||||
let mut args = args.into_iter().skip(bind_index + 2).peekable();
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "--" {
|
||||
return args.next().map(|arg| strip_shell_quotes(&arg).to_owned());
|
||||
}
|
||||
if matches!(arg.as_str(), "-r" | "--revision" | "--change") {
|
||||
args.next();
|
||||
continue;
|
||||
}
|
||||
if arg.starts_with("--revision=") || arg.starts_with("--change=") {
|
||||
continue;
|
||||
}
|
||||
if arg.starts_with("-r") && arg.len() > 2 {
|
||||
continue;
|
||||
}
|
||||
if arg.starts_with('-') || arg.is_empty() {
|
||||
continue;
|
||||
}
|
||||
return Some(strip_shell_quotes(&arg).to_owned());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn strip_shell_quotes(s: &str) -> &str {
|
||||
if s.len() >= 2
|
||||
&& (s.starts_with('"') && s.ends_with('"') || s.starts_with('\'') && s.ends_with('\''))
|
||||
|
||||
@@ -106,6 +106,8 @@ This document contains the help content for the `jj` command-line program.
|
||||
* [`jj split`↴](#jj-split)
|
||||
* [`jj squash`↴](#jj-squash)
|
||||
* [`jj status`↴](#jj-status)
|
||||
* [`jj submodule`↴](#jj-submodule)
|
||||
* [`jj submodule bind`↴](#jj-submodule-bind)
|
||||
* [`jj tag`↴](#jj-tag)
|
||||
* [`jj tag delete`↴](#jj-tag-delete)
|
||||
* [`jj tag list`↴](#jj-tag-list)
|
||||
@@ -187,6 +189,7 @@ To get started, see the tutorial [`jj help -k tutorial`].
|
||||
* `split` — Split a revision in two
|
||||
* `squash` — Move changes from a revision into another revision
|
||||
* `status` — Show high-level repo status [default alias: st]
|
||||
* `submodule` — Manage Git submodules
|
||||
* `tag` — Manage tags
|
||||
* `undo` — Undo the last operation
|
||||
* `unsign` — Drop a cryptographic signature
|
||||
@@ -3312,6 +3315,37 @@ Note: You can use `jj diff --summary -r <rev>` to see the changed files for a sp
|
||||
|
||||
|
||||
|
||||
## `jj submodule`
|
||||
|
||||
Manage Git submodules
|
||||
|
||||
**Usage:** `jj submodule <COMMAND>`
|
||||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `bind` — Explicitly update a submodule gitlink
|
||||
|
||||
|
||||
|
||||
## `jj submodule bind`
|
||||
|
||||
Explicitly update a submodule gitlink.
|
||||
|
||||
The submodule revision is resolved in the submodule repository at PATH. The selected superproject revision is then rewritten so PATH stores that commit as a `160000` Git submodule tree entry. The superproject working-copy snapshot does not update submodule gitlinks automatically.
|
||||
|
||||
**Usage:** `jj submodule bind [OPTIONS] --revision <SUBMODULE_REV> <PATH>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<PATH>` — Submodule path to update
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `-r`, `--revision <SUBMODULE_REV>` — Revision to resolve in the submodule repository
|
||||
* `--change <REVSET>` — Superproject revision to rewrite
|
||||
|
||||
|
||||
|
||||
## `jj tag`
|
||||
|
||||
Manage tags
|
||||
|
||||
@@ -82,6 +82,7 @@ mod test_sparse_command;
|
||||
mod test_split_command;
|
||||
mod test_squash_command;
|
||||
mod test_status_command;
|
||||
mod test_submodule_command;
|
||||
mod test_tag_command;
|
||||
mod test_templater;
|
||||
mod test_undo_redo_commands;
|
||||
|
||||
346
cli/tests/test_submodule_command.rs
Normal file
346
cli/tests/test_submodule_command.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
// Copyright 2026 The Jujutsu Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
|
||||
use crate::common::CommandOutput;
|
||||
use crate::common::TestEnvironment;
|
||||
use crate::common::TestWorkDir;
|
||||
|
||||
fn normalize_git_hashes(output: CommandOutput) -> CommandOutput {
|
||||
output
|
||||
.normalize_stdout_with(normalize_git_hashes_in_text)
|
||||
.normalize_stderr_with(normalize_git_hashes_in_text)
|
||||
}
|
||||
|
||||
fn normalize_git_hashes_in_text(text: String) -> String {
|
||||
let text = regex::Regex::new(r"[0-9a-f]{10,40}")
|
||||
.unwrap()
|
||||
.replace_all(&text, "<git-commit>")
|
||||
.into_owned();
|
||||
regex::Regex::new(r"[k-z]{8} [0-9a-f]{8}")
|
||||
.unwrap()
|
||||
.replace_all(&text, "<jj-commit>")
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn machine_completion_values(output: CommandOutput) -> Vec<String> {
|
||||
let output = output.success();
|
||||
let candidates: Vec<serde_json::Value> = serde_json::from_str(output.stdout.raw()).unwrap();
|
||||
candidates
|
||||
.into_iter()
|
||||
.map(|candidate| candidate["value"].as_str().unwrap().to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_git<I, S>(work_dir: &TestWorkDir<'_>, args: I)
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
let output = std::process::Command::new("git")
|
||||
.current_dir(work_dir.root())
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git command failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submodule_bind_updates_existing_gitlink() {
|
||||
let test_env = TestEnvironment::default();
|
||||
|
||||
test_env
|
||||
.run_jj_in(".", ["git", "init", "--colocate", "submodule"])
|
||||
.success();
|
||||
let submodule_dir = test_env.work_dir("submodule");
|
||||
submodule_dir.write_file("file", "first");
|
||||
submodule_dir
|
||||
.run_jj(["commit", "-m", "first submodule commit"])
|
||||
.success();
|
||||
let old_target = submodule_dir
|
||||
.run_jj(["log", "-r@-", "--no-graph", "-T", "commit_id"])
|
||||
.success()
|
||||
.stdout
|
||||
.raw()
|
||||
.trim()
|
||||
.to_owned();
|
||||
submodule_dir.write_file("file", "second");
|
||||
submodule_dir
|
||||
.run_jj(["commit", "-m", "second submodule commit"])
|
||||
.success();
|
||||
let new_target = submodule_dir
|
||||
.run_jj(["log", "-r@-", "--no-graph", "-T", "commit_id"])
|
||||
.success()
|
||||
.stdout
|
||||
.raw()
|
||||
.trim()
|
||||
.to_owned();
|
||||
|
||||
test_env
|
||||
.run_jj_in(".", ["git", "init", "--colocate", "repo"])
|
||||
.success();
|
||||
let work_dir = test_env.work_dir("repo");
|
||||
let submodule_url = format!("{}/submodule", test_env.env_root().display());
|
||||
run_git(
|
||||
&work_dir,
|
||||
[
|
||||
"-c",
|
||||
"protocol.file.allow=always",
|
||||
"submodule",
|
||||
"add",
|
||||
submodule_url.as_str(),
|
||||
"sub",
|
||||
],
|
||||
);
|
||||
run_git(&work_dir, ["-C", "sub", "checkout", &old_target]);
|
||||
run_git(
|
||||
&work_dir,
|
||||
["-C", "sub", "branch", "new-target", &new_target],
|
||||
);
|
||||
run_git(&work_dir, ["add", "sub"]);
|
||||
run_git(
|
||||
&work_dir,
|
||||
[
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"-c",
|
||||
"user.name=Test user",
|
||||
"commit",
|
||||
"-m",
|
||||
"add submodule",
|
||||
],
|
||||
);
|
||||
|
||||
insta::assert_snapshot!(work_dir.run_jj(["diff", "--summary"]), @r#"
|
||||
------- stderr -------
|
||||
ignoring git submodule at "sub"
|
||||
Done importing changes from the underlying Git repo.
|
||||
[EOF]
|
||||
"#);
|
||||
|
||||
let output = work_dir.run_jj(["submodule", "bind", "sub", "-r", "new-target"]);
|
||||
insta::assert_snapshot!(normalize_git_hashes(output), @r#"
|
||||
------- stderr -------
|
||||
Updated Git submodule sub:
|
||||
old: <git-commit>
|
||||
new: <git-commit>
|
||||
Rewritten superproject commit: <jj-commit> (no description set)
|
||||
ignoring git submodule at "sub"
|
||||
Working copy (@) now at: <jj-commit> (no description set)
|
||||
Parent commit (@-) : <jj-commit> master | add submodule
|
||||
Added 0 files, modified 1 files, removed 0 files
|
||||
[EOF]
|
||||
"#);
|
||||
|
||||
insta::assert_snapshot!(work_dir.run_jj(["diff", "--summary"]), @r#"
|
||||
M sub
|
||||
[EOF]
|
||||
"#);
|
||||
let diff_git = work_dir.run_jj(["diff", "--git"]);
|
||||
assert!(diff_git.stdout.raw().contains(&new_target[..10]));
|
||||
insta::assert_snapshot!(normalize_git_hashes(diff_git), @r#"
|
||||
diff --git a/sub b/sub
|
||||
index <git-commit>..<git-commit> 160000
|
||||
[EOF]
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submodule_bind_revision_completion_uses_jj_submodule() {
|
||||
let test_env = TestEnvironment::default();
|
||||
|
||||
test_env
|
||||
.run_jj_in(".", ["git", "init", "--colocate", "repo"])
|
||||
.success();
|
||||
let work_dir = test_env.work_dir("repo");
|
||||
work_dir
|
||||
.run_jj(["bookmark", "create", "super-target", "-r", "@"])
|
||||
.success();
|
||||
|
||||
work_dir.create_dir("sub");
|
||||
test_env
|
||||
.run_jj_in("repo/sub", ["git", "init", "--colocate", "."])
|
||||
.success();
|
||||
let submodule_dir = test_env.work_dir("repo/sub");
|
||||
submodule_dir.write_file("file", "contents");
|
||||
submodule_dir
|
||||
.run_jj(["commit", "-m", "submodule commit"])
|
||||
.success();
|
||||
submodule_dir
|
||||
.run_jj(["bookmark", "create", "sub-target", "-r", "@-"])
|
||||
.success();
|
||||
|
||||
let values = machine_completion_values(work_dir.run_jj([
|
||||
"util",
|
||||
"complete",
|
||||
"--index",
|
||||
"5",
|
||||
"--",
|
||||
"jj",
|
||||
"submodule",
|
||||
"bind",
|
||||
"sub",
|
||||
"-r",
|
||||
"sub-",
|
||||
]));
|
||||
assert!(values.contains(&"sub-target".to_owned()));
|
||||
assert!(!values.contains(&"super-target".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submodule_bind_revision_completion_uses_git_submodule() {
|
||||
let test_env = TestEnvironment::default();
|
||||
|
||||
test_env
|
||||
.run_jj_in(".", ["git", "init", "--colocate", "repo"])
|
||||
.success();
|
||||
let work_dir = test_env.work_dir("repo");
|
||||
work_dir
|
||||
.run_jj(["bookmark", "create", "super-target", "-r", "@"])
|
||||
.success();
|
||||
|
||||
work_dir.create_dir("sub");
|
||||
work_dir.write_file("sub/file", "contents");
|
||||
run_git(&work_dir, ["-C", "sub", "init"]);
|
||||
run_git(&work_dir, ["-C", "sub", "add", "file"]);
|
||||
run_git(
|
||||
&work_dir,
|
||||
[
|
||||
"-C",
|
||||
"sub",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"-c",
|
||||
"user.name=Test user",
|
||||
"commit",
|
||||
"-m",
|
||||
"submodule commit",
|
||||
],
|
||||
);
|
||||
run_git(&work_dir, ["-C", "sub", "branch", "sub-target"]);
|
||||
|
||||
let values = machine_completion_values(work_dir.run_jj([
|
||||
"util",
|
||||
"complete",
|
||||
"--index",
|
||||
"5",
|
||||
"--",
|
||||
"jj",
|
||||
"submodule",
|
||||
"bind",
|
||||
"sub",
|
||||
"-r",
|
||||
"sub-",
|
||||
]));
|
||||
assert!(values.contains(&"sub-target".to_owned()));
|
||||
assert!(!values.contains(&"super-target".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submodule_bind_at_resolves_in_jj_submodule() {
|
||||
let test_env = TestEnvironment::default();
|
||||
|
||||
test_env
|
||||
.run_jj_in(".", ["git", "init", "--colocate", "submodule"])
|
||||
.success();
|
||||
let submodule_dir = test_env.work_dir("submodule");
|
||||
submodule_dir.write_file("file", "contents");
|
||||
submodule_dir
|
||||
.run_jj(["commit", "-m", "submodule commit"])
|
||||
.success();
|
||||
let old_target = submodule_dir
|
||||
.run_jj(["log", "-r@-", "--no-graph", "-T", "commit_id"])
|
||||
.success()
|
||||
.stdout
|
||||
.raw()
|
||||
.trim()
|
||||
.to_owned();
|
||||
|
||||
test_env
|
||||
.run_jj_in(".", ["git", "init", "--colocate", "repo"])
|
||||
.success();
|
||||
let work_dir = test_env.work_dir("repo");
|
||||
let submodule_url = format!("{}/submodule", test_env.env_root().display());
|
||||
run_git(
|
||||
&work_dir,
|
||||
[
|
||||
"-c",
|
||||
"protocol.file.allow=always",
|
||||
"submodule",
|
||||
"add",
|
||||
submodule_url.as_str(),
|
||||
"sub",
|
||||
],
|
||||
);
|
||||
run_git(&work_dir, ["-C", "sub", "checkout", &old_target]);
|
||||
run_git(&work_dir, ["add", "sub"]);
|
||||
run_git(
|
||||
&work_dir,
|
||||
[
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"-c",
|
||||
"user.name=Test user",
|
||||
"commit",
|
||||
"-m",
|
||||
"add submodule",
|
||||
],
|
||||
);
|
||||
insta::assert_snapshot!(work_dir.run_jj(["diff", "--summary"]), @r#"
|
||||
------- stderr -------
|
||||
ignoring git submodule at "sub"
|
||||
Done importing changes from the underlying Git repo.
|
||||
[EOF]
|
||||
"#);
|
||||
|
||||
test_env
|
||||
.run_jj_in("repo/sub", ["git", "init", "--git-repo=."])
|
||||
.success();
|
||||
let jj_wc_target = test_env
|
||||
.run_jj_in("repo/sub", ["log", "-r@", "--no-graph", "-T", "commit_id"])
|
||||
.success()
|
||||
.stdout
|
||||
.raw()
|
||||
.trim()
|
||||
.to_owned();
|
||||
assert_ne!(jj_wc_target, old_target);
|
||||
|
||||
let output = work_dir.run_jj(["submodule", "bind", "sub", "-r", "@"]);
|
||||
insta::assert_snapshot!(normalize_git_hashes(output), @r#"
|
||||
------- stderr -------
|
||||
Updated Git submodule sub:
|
||||
old: <git-commit>
|
||||
new: <git-commit>
|
||||
Rewritten superproject commit: <jj-commit> (no description set)
|
||||
ignoring git submodule at "sub"
|
||||
Working copy (@) now at: <jj-commit> (no description set)
|
||||
Parent commit (@-) : <jj-commit> master | add submodule
|
||||
Added 0 files, modified 1 files, removed 0 files
|
||||
[EOF]
|
||||
"#);
|
||||
|
||||
let diff_git = work_dir.run_jj(["diff", "--git"]);
|
||||
assert!(diff_git.stdout.raw().contains(&jj_wc_target[..10]));
|
||||
insta::assert_snapshot!(normalize_git_hashes(diff_git), @r#"
|
||||
diff --git a/sub b/sub
|
||||
index <git-commit>..<git-commit> 160000
|
||||
[EOF]
|
||||
"#);
|
||||
}
|
||||
@@ -97,8 +97,7 @@ pub async fn git_diff_part(
|
||||
};
|
||||
}
|
||||
MaterializedTreeValue::GitSubmodule(id) => {
|
||||
// TODO: What should we actually do here?
|
||||
mode = "040000";
|
||||
mode = "160000";
|
||||
hash = id.hex();
|
||||
content = FileContent {
|
||||
is_binary: false,
|
||||
|
||||
Reference in New Issue
Block a user