cli: add jj submodule bind

This commit is contained in:
Yuantao Wang
2026-06-25 18:38:17 +08:00
parent 70945e1b15
commit f3195987c2
10 changed files with 838 additions and 2 deletions

3
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -57,6 +57,7 @@ gix = { version = "0.84.0", default-features = false, features = [
"blob-diff",
"index",
"max-performance-safe",
"revision",
"sha1",
"zlib-rs",
] }

View File

@@ -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> {

View File

@@ -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,

View 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())
}

View File

@@ -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('\''))

View File

@@ -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

View File

@@ -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;

View 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]
"#);
}

View File

@@ -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,