Skip to content
Merged
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
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions src/git_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1301,9 +1301,49 @@ impl GitManager {
}
}

// Ensure thorough cleanup of git state left behind by git2 (which uses the *path*
// as the submodule key rather than the *name*). The gix/git2 cleanups above key
// on the name, so path-based artifacts may linger and prevent a clean re-add.
if let Some(workdir) = self.git_ops.workdir() {
let workdir = workdir.to_path_buf();

// Remove path-based git config section (created by git2 during add)
if path != name {
let _ = std::process::Command::new("git")
.args(["config", "--remove-section", &format!("submodule.{path}")])
.current_dir(&workdir)
.output();
}
// Also ensure name-based config section is gone
let _ = std::process::Command::new("git")
.args(["config", "--remove-section", &format!("submodule.{name}")])
.current_dir(&workdir)
.output();

// Remove path-based .git/modules directory (created by git2 using path as key)
let path_modules_dir = workdir.join(".git").join("modules").join(&path);
if path_modules_dir.exists() {
let _ = fs::remove_dir_all(&path_modules_dir);
}
// Also ensure name-based .git/modules directory is gone
let name_modules_dir = workdir.join(".git").join("modules").join(name);
if name_modules_dir.exists() {
let _ = fs::remove_dir_all(&name_modules_dir);
}
}

// Remove from config
self.config.submodules.remove_submodule(name);
self.write_full_config()?;

// Reopen the git repository to flush any cached state (git2 caches internal state
// about submodules and will fail on subsequent add_submodule calls if not refreshed).
if let Err(e) = self.git_ops.reopen() {
eprintln!(
"Warning: failed to refresh git repository state after deleting submodule '{name}': {e}"
);
}

println!("Deleted submodule '{name}'.");
Ok(())
}
Expand Down
67 changes: 66 additions & 1 deletion src/git_ops/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,45 @@ impl GitOpsManager {
Ok(Self { gix_ops, git2_ops })
}

/// Return the working directory of the underlying git repository, if any.
pub fn workdir(&self) -> Option<&std::path::Path> {
self.git2_ops.workdir()
}

/// Reopen the repository from the working directory to refresh any cached state.
/// This is needed after destructive operations (e.g., submodule delete) so that the
/// in-memory git2 repository object reflects the updated on-disk state.
///
/// Returns an error if the git2 repository (the required backend) cannot be reopened.
/// A gix reopen failure is non-fatal since gix is an optional optimistic backend.
pub fn reopen(&mut self) -> Result<()> {
let workdir = self
.git2_ops
.workdir()
.ok_or_else(|| anyhow::anyhow!("Cannot reopen repository: no working directory"))?
.to_path_buf();

// git2 is the required backend — propagate its reopen error.
self.git2_ops = Git2Operations::new(Some(&workdir))
.with_context(|| format!("Failed to reopen git2 repository at {}", workdir.display()))?;

// gix is an optional optimistic backend — log failures but don't fail.
match GixOperations::new(Some(&workdir)) {
Ok(new_gix) => {
self.gix_ops = Some(new_gix);
}
Err(e) => {
eprintln!(
"Warning: failed to reopen gix repository at {}: {}",
workdir.display(),
e
);
}
}

Ok(())
}

/// Try gix first, fall back to git2
fn try_with_fallback<T, F1, F2>(&self, gix_op: F1, git2_op: F2) -> Result<T>
where
Expand Down Expand Up @@ -296,10 +335,19 @@ impl GitOperations for GitOpsManager {
// Also git2 might have added it to .git/config
let gitconfig_path = workdir.join(".git").join("config");
if gitconfig_path.exists() {
// Remove by name (our submodule name)
let _ = std::process::Command::new("git")
.args(["config", "--remove-section", &format!("submodule.{}", opts.name)])
.current_dir(workdir)
.output();
// Remove by path (git2 uses path as key when name != path)
let path_key = opts.path.display().to_string();
if path_key != opts.name {
let _ = std::process::Command::new("git")
.args(["config", "--remove-section", &format!("submodule.{path_key}")])
.current_dir(workdir)
.output();
}
}

// Also git2 might have created the internal git directory
Expand All @@ -308,6 +356,14 @@ impl GitOperations for GitOpsManager {
let _ = std::fs::remove_dir_all(&internal_git_dir);
}

// git2's repo.submodule() uses the *path* (not the name) as the key for the
// internal modules directory, so ".git/modules/lib/reinit" may exist even when
// ".git/modules/<name>" has already been cleaned up. Remove both.
let path_internal_git_dir = workdir.join(".git").join("modules").join(&opts.path);
if path_internal_git_dir.exists() {
let _ = std::fs::remove_dir_all(&path_internal_git_dir);
}

// And removed from index
let _ = std::process::Command::new("git")
.args(["rm", "--cached", "-r", "--ignore-unmatch"])
Expand All @@ -322,7 +378,16 @@ impl GitOperations for GitOpsManager {
.arg("--name")
.arg(&opts.name);
if let Some(branch) = &opts.branch {
cmd.arg("--branch").arg(branch.to_string());
let branch_str = branch.to_string();
// "." is the gitmodules/git-config token meaning "track the same branch as
// the superproject" (SerializableBranch::CurrentInSuperproject). It is only
// meaningful as a stored config value; passing it as `--branch .` to
// `git submodule add` is invalid and causes:
// fatal: 'HEAD' is not a valid branch name
// Skip the flag so git resolves the remote's default branch automatically.
if branch_str != "." {
cmd.arg("--branch").arg(&branch_str);
}
}
if opts.shallow {
cmd.arg("--depth").arg("1");
Expand Down
Loading
Loading