From bd80284068f6e513164dde73ce3f4605a0cb2c8a Mon Sep 17 00:00:00 2001 From: Pistonight Date: Sat, 25 Nov 2023 00:26:36 -0800 Subject: [PATCH] implement install and big refactor --- Cargo.lock | 99 +----- Cargo.toml | 1 - README.md | 35 ++- README.txtpp.md | 39 ++- src/git.rs | 556 +++++++++++++++++++++++++++++++++ src/git/context.rs | 755 --------------------------------------------- src/git/mod.rs | 70 ----- src/lib.rs | 109 ++++--- src/main.rs | 7 +- src/print.rs | 60 +--- src/status.rs | 402 ++++++++++++++++++++++++ src/submodule.rs | 51 +-- 12 files changed, 1110 insertions(+), 1074 deletions(-) create mode 100644 src/git.rs delete mode 100644 src/git/context.rs delete mode 100644 src/git/mod.rs create mode 100644 src/status.rs diff --git a/Cargo.lock b/Cargo.lock index e29f724..110299f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,23 +50,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi", - "libc", - "winapi 0.3.9", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.4.1" @@ -151,15 +134,6 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - [[package]] name = "home" version = "0.5.5" @@ -169,16 +143,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "kernel32-sys" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" -dependencies = [ - "winapi 0.2.8", - "winapi-build", -] - [[package]] name = "libc" version = "0.2.150" @@ -199,17 +163,10 @@ dependencies = [ "fs4", "pathdiff", "termcolor", - "termsize", "thiserror", "which", ] -[[package]] -name = "numtoa" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" - [[package]] name = "once_cell" version = "1.18.0" @@ -240,28 +197,13 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_termios" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" - [[package]] name = "rustix" version = "0.38.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc99bc2d4f1fed22595588a013687477aedf3cdcfb26558c559edb67b4d9b22e" dependencies = [ - "bitflags 2.4.1", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -294,31 +236,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "termion" -version = "1.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "077185e2eac69c3f8379a4298e1e07cd36beb962290d4a51199acf0fdc10607e" -dependencies = [ - "libc", - "numtoa", - "redox_syscall", - "redox_termios", -] - -[[package]] -name = "termsize" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e86d824a8e90f342ad3ef4bd51ef7119a9b681b0cc9f8ee7b2852f02ccd2517" -dependencies = [ - "atty", - "kernel32-sys", - "libc", - "termion", - "winapi 0.2.8", -] - [[package]] name = "thiserror" version = "1.0.50" @@ -364,12 +281,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "winapi" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" - [[package]] name = "winapi" version = "0.3.9" @@ -380,12 +291,6 @@ dependencies = [ "winapi-x86_64-pc-windows-gnu", ] -[[package]] -name = "winapi-build" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" - [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" @@ -398,7 +303,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" dependencies = [ - "winapi 0.3.9", + "winapi", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8bd2176..ea2bd09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,6 @@ clap = { version = "4.4.8", features = ["derive"], optional = true } fs4 = "0.7.0" pathdiff = "0.2.1" termcolor = "1.4.0" -termsize = "0.1.6" thiserror = "1.0.50" which = "5.0.0" diff --git a/README.md b/README.md index c216cb2..89cab0a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![License Badge](https://img.shields.io/github/license/Pistonite/magoo) ![Issue Badge](https://img.shields.io/github/issues/Pistonite/magoo) -**In Development. commands left are: install, update, remove** +**In Development. commands left are: update, remove** This ![magoo](https://raw.githubusercontent.com/Pistonite/magoo/main/magoo.webp) is Magoo, he helps you manage git submodules with ease, like `npm` or `cargo`, but for submodules. @@ -45,15 +45,23 @@ the officially supported `git` versions. Unsupported versions might work as well ### Add a submodule -To add a submodule, ![magoo](https://raw.githubusercontent.com/Pistonite/magoo/main/magoo.webp) needs to know: -- `URL`: The git URL like https://github.com/owner/repo -- `PATH`: The path in your repo the module should be at -- Optionally, `BRANCH`: The branch to update to when you run `magoo update` + +The argument for adding a submodule is very similar to [`git submodule add`](https://git-scm.com/docs/git-submodule#Documentation/git-submodule.txt-add-bltbranchgt-f--force--nameltnamegt--referenceltrepositorygt--depthltdepthgt--ltrepositorygtltpathgt) + +![magoo](https://raw.githubusercontent.com/Pistonite/magoo/main/magoo.webp) needs to know the following to add a submodule.: + +|Arg|Description|Default| +|-|-|-| +|`URL`| The git URL like `https://github.com/owner/repo`. | URL is Required | +|`PATH`| The path in your repo the module should be at | Directory at the top level with the same name as the submodule repo| +|`BRANCH`| The branch to update to when you run `magoo update` | None (`HEAD`) | +|`NAME`| Name to identify the submodule for other commands | same as `PATH` | It's recommended to always specify the `BRANCH`. Git by default will use the `HEAD` branch, which is usually not what you want. ```bash +magoo install URL --branch BRANCH magoo install URL PATH --branch BRANCH magoo install URL PATH --branch BRANCH --name NAME --depth DEPTH --force ``` @@ -64,19 +72,22 @@ Run `magoo install help` to see other options ```bash magoo install ``` -This will ensure the submodules are cloned/updated to the commit stored in the index. -You should run `magoo install` every time you pull - similar to `npm install`. -It also deletes submodules that are deleted by others. +![magoo](https://raw.githubusercontent.com/Pistonite/magoo/main/magoo.webp) will ensure the submodules are cloned/updated to the commit stored in the index. +You should run `magoo install` every time you pull the changes from others, in case they were updated. +It also deletes submodules that are deleted by others (by running `status --fix --all`, see below). ### Show submodule status ```bash -magoo status -magoo status --fix +magoo status [--all] +magoo status --fix [--all] ``` Shows everything ![magoo](https://raw.githubusercontent.com/Pistonite/magoo/main/magoo.webp) knows about submodules in the current repo. -If you have tinkered with submodules yourself, ![magoo](https://raw.githubusercontent.com/Pistonite/magoo/main/magoo.webp) might not like the state since -there could be inconsistencies. ![magoo](https://raw.githubusercontent.com/Pistonite/magoo/main/magoo.webp) will tell you what he doesn't like, and the `--fix` option will fix those. +The `--fix` option will bring the submodule states back to a consistent state that ![magoo](https://raw.githubusercontent.com/Pistonite/magoo/main/magoo.webp) likes. +The state could be inconsistent if the git files were changed manually or by running +individual `git` commands, or by a remote change. + +The `--all` option can potentially find more residues. ### Update submodules diff --git a/README.txtpp.md b/README.txtpp.md index b040b98..f8286e1 100644 --- a/README.txtpp.md +++ b/README.txtpp.md @@ -7,7 +7,7 @@ TXTPP#include magoo.txt ![License Badge](https://img.shields.io/github/license/Pistonite/magoo) ![Issue Badge](https://img.shields.io/github/issues/Pistonite/magoo) -**In Development. commands left are: install, update, remove** +**In Development. commands left are: update, remove** TXTPP#tag MAGOO TXTPP#include magoo.txt @@ -69,17 +69,25 @@ the officially supported `git` versions. Unsupported versions might work as well ### Add a submodule + +The argument for adding a submodule is very similar to [`git submodule add`](https://git-scm.com/docs/git-submodule#Documentation/git-submodule.txt-add-bltbranchgt-f--force--nameltnamegt--referenceltrepositorygt--depthltdepthgt--ltrepositorygtltpathgt) + TXTPP#tag MAGOO TXTPP#include magoo.txt -To add a submodule, MAGOO needs to know: -- `URL`: The git URL like https://github.com/owner/repo -- `PATH`: The path in your repo the module should be at -- Optionally, `BRANCH`: The branch to update to when you run `magoo update` +MAGOO needs to know the following to add a submodule.: + +|Arg|Description|Default| +|-|-|-| +|`URL`| The git URL like `https://github.com/owner/repo`. | URL is Required | +|`PATH`| The path in your repo the module should be at | Directory at the top level with the same name as the submodule repo| +|`BRANCH`| The branch to update to when you run `magoo update` | None (`HEAD`) | +|`NAME`| Name to identify the submodule for other commands | same as `PATH` | It's recommended to always specify the `BRANCH`. Git by default will use the `HEAD` branch, which is usually not what you want. ```bash +magoo install URL --branch BRANCH magoo install URL PATH --branch BRANCH magoo install URL PATH --branch BRANCH --name NAME --depth DEPTH --force ``` @@ -90,14 +98,16 @@ Run `magoo install help` to see other options ```bash magoo install ``` -This will ensure the submodules are cloned/updated to the commit stored in the index. -You should run `magoo install` every time you pull - similar to `npm install`. -It also deletes submodules that are deleted by others. +TXTPP#tag MAGOO +TXTPP#include magoo.txt +MAGOO will ensure the submodules are cloned/updated to the commit stored in the index. +You should run `magoo install` every time you pull the changes from others, in case they were updated. +It also deletes submodules that are deleted by others (by running `status --fix --all`, see below). ### Show submodule status ```bash -magoo status -magoo status --fix +magoo status [--all] +magoo status --fix [--all] ``` TXTPP#tag MAGOO TXTPP#include magoo.txt @@ -105,10 +115,11 @@ Shows everything MAGOO knows about submodules in the current repo. TXTPP#tag MAGOO TXTPP#include magoo.txt -If you have tinkered with submodules yourself, MAGOO might not like the state since -TXTPP#tag MAGOO -TXTPP#include magoo.txt -there could be inconsistencies. MAGOO will tell you what he doesn't like, and the `--fix` option will fix those. +The `--fix` option will bring the submodule states back to a consistent state that MAGOO likes. +The state could be inconsistent if the git files were changed manually or by running +individual `git` commands, or by a remote change. + +The `--all` option can potentially find more residues. ### Update submodules TXTPP#tag MAGOO diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..47e3850 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,556 @@ +//! Low level integration with git +use std::borrow::Cow; +use std::cell::OnceCell; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus, Stdio}; +use std::time::Duration; + +use fs4::FileExt; + +use crate::print::{self, println_info, println_verbose, println_warn}; + +/// The semver notation of the officially supported git versions +/// +/// The version is not checked at run time, since unsupported versions might work fine. +pub const SUPPORTED_GIT_VERSIONS: &str = ">=2.35.0"; + +/// Context for running git commands +pub struct GitContext { + /// The absolute path of the working directory to run the git commands + working_dir: PathBuf, + + /// The path to the .git directory + /// + /// This is retrieved from `git rev-parse --git-dir` and cached. + git_dir_cell: OnceCell, + + /// The path to the top level directory + /// + /// This is retrieved from `git rev-parse --show-toplevel` and cached. + top_level_cell: OnceCell, +} + +/// Implementation for basic operations +impl GitContext { + /// Create a new GitContext for running git commands in the given working directory + pub fn try_from(working_dir: S) -> Result + where + S: AsRef, + { + if which::which("git").is_err() { + return Err(GitError::NotInstalled); + } + Ok(Self { + working_dir: working_dir.as_ref().canonicalize_git()?, + git_dir_cell: OnceCell::new(), + top_level_cell: OnceCell::new(), + }) + } + + /// Return a guard that locks the repository until dropped. Other magoo processes cannot access + /// the repository while the guard is alive. + pub fn lock(&self) -> Result { + let git_dir = self.git_dir()?; + let lock_path = git_dir.join("magoo.lock"); + Guard::new(lock_path) + } + + /// Print the supported git version info and current git version into + pub fn print_version_info(&self) -> Result<(), GitError> { + println_info!( + "The officially supported git versions are: {}", + super::SUPPORTED_GIT_VERSIONS + ); + println_info!("Your `git --version` is:"); + self.run_git_command(&["--version"], true)?; + Ok(()) + } + + /// Get the absolute path to the .git directory + pub fn git_dir(&self) -> Result<&PathBuf, GitError> { + if let Some(git_dir) = self.git_dir_cell.get() { + return Ok(git_dir); + } + + let git_dir_path = match self.git_dir_raw()? { + Some(x) => x, + None => { + return Err(GitError::UnexpectedOutput( + "git did not return the .git directory".to_string(), + )); + } + }; + let path = self.working_dir.join(git_dir_path).canonicalize_git()?; + + self.git_dir_cell.set(path).unwrap(); + Ok(self.git_dir_cell.get().unwrap()) + } + + /// Return the raw output of `git rev-parse --git-dir` + pub fn git_dir_raw(&self) -> Result, GitError> { + let output = self.run_git_command(&["rev-parse", "--git-dir"], false)?; + Ok(output.into_iter().next()) + } + + /// Get the absolute path to the top level directory + pub fn top_level_dir(&self) -> Result<&PathBuf, GitError> { + if let Some(top_level) = self.top_level_cell.get() { + return Ok(top_level); + } + + let output = self.run_git_command(&["rev-parse", "--show-toplevel"], false)?; + let top_dir_path = output.first().ok_or_else(|| { + GitError::UnexpectedOutput("git did not return the top level directory".to_string()) + })?; + let path = self.working_dir.join(top_dir_path).canonicalize_git()?; + + self.top_level_cell.set(path).unwrap(); + Ok(self.top_level_cell.get().unwrap()) + } + + /// Get the path in `git -C ...` to run the command in the top level directory + pub fn get_top_level_switch(&self) -> Result, GitError> { + let top_level_dir = self.top_level_dir()?; + + let command = match Path::new(".").canonicalize() { + Ok(cwd) => { + if &cwd == top_level_dir { + None + } else { + let path = pathdiff::diff_paths(top_level_dir, &cwd) + .unwrap_or(top_level_dir.to_path_buf()); + let diff = path.display().to_string(); + Some(quote_arg(&diff).to_string()) + } + } + Err(_) => { + let top_level = top_level_dir.display().to_string(); + Some(quote_arg(&top_level).to_string()) + } + }; + + Ok(command) + } + + /// Run the git command from self's working directory. The output of the command will be returned as a vector of lines. + fn run_git_command(&self, args: &[&str], print: bool) -> Result, GitError> { + let args_str = args + .iter() + .map(|x| { + if x.contains(' ') { + format!("'{x}'") + } else { + x.to_string() + } + }) + .collect::>() + .join(" "); + let command = format!("git {args_str}"); + println_verbose!("Running `{command}`"); + + let mut child = Command::new("git") + .args(args) + .current_dir(&self.working_dir) + .stdout(Stdio::piped()) + .stderr(if !print { + Stdio::piped() + } else { + Stdio::inherit() + }) + .spawn() + .map_err(|e| { + GitError::CommandFailed(command.clone(), "failed to spawn process".to_string(), e) + })?; + + let mut output = Vec::new(); + if let Some(stdout) = child.stdout.take() { + let reader = BufReader::new(stdout); + for line in reader.lines() { + let line = line.map_err(|e| { + GitError::CommandFailed(command.clone(), "failed to read output".to_string(), e) + })?; + if print { + println_info!("{line}"); + } + output.push(line); + } + } + + if print::is_verbose() { + if let Some(stderr) = child.stderr.take() { + let reader = BufReader::new(stderr); + for line in reader.lines().flatten() { + println_verbose!("{line}"); + } + } + } + let status = child.wait().map_err(|e| { + GitError::CommandFailed( + command.clone(), + "command did not finish normally".to_string(), + e, + ) + })?; + println_verbose!("Git command finished: {}", status); + if status.success() { + Ok(output) + } else { + Err(GitError::ExitStatus(command, status)) + } + } +} + +/// Wrapper implementation for git commands +impl GitContext { + /// Run `git -C top_level ls-files ...` + pub fn ls_files(&self, extra_args: &[&str]) -> Result, GitError> { + let top_level_dir = self.top_level_dir()?.display().to_string(); + let mut args = vec!["-C", &top_level_dir, "ls-files"]; + args.extend_from_slice(extra_args); + self.run_git_command(&args, false) + } + + /// Run `git describe --all ` and return the first output + pub fn describe(&self, commit: &str) -> Option { + self.run_git_command(&["describe", "--all", commit], false) + .ok() + .and_then(|x| x.into_iter().next()) + } + + /// Run `git rev-parse HEAD` + pub fn head(&self) -> Result, GitError> { + let output = self.run_git_command(&["rev-parse", "HEAD"], false)?; + Ok(output.into_iter().next()) + } + + /// Run `git config -f config_path --get key` + /// + /// The config path is resolved relative to the working directory of this context. + pub fn get_config(&self, config_path: S, key: &str) -> Result, GitError> + where + S: AsRef, + { + let config_path = config_path.as_ref().display().to_string(); + let value = self + .run_git_command(&["config", "-f", &config_path, "--get", key], false)? + .into_iter() + .next(); + Ok(value) + } + + /// Calls `git config -f config_path ... --get-regexp regexp` to get (key, value) pairs in the config file + /// + /// The config path is resolved relative to the working directory of this context. + pub fn get_config_regexp( + &self, + config_path: S, + regexp: &str, + ) -> Result, GitError> + where + S: AsRef, + { + let config_path = config_path.as_ref().display().to_string(); + let name_and_values = self.run_git_command( + &["config", "-f", &config_path, "--get-regexp", regexp], + false, + )?; + let name_only = self.run_git_command( + &[ + "config", + "-f", + &config_path, + "--name-only", + "--get-regexp", + regexp, + ], + false, + )?; + + let mut name_values = Vec::new(); + for (name, name_and_value) in name_only.iter().zip(name_and_values.iter()) { + match name_and_value.strip_prefix(name) { + Some(value) => { + name_values.push((name.trim().to_string(), value.trim().to_string())); + } + None => { + return Err(GitError::InvalidConfig( + "unexpected config key mismatch in git output.".to_string(), + )); + } + } + } + + Ok(name_values) + } + + /// Calls `git config` to set or remove a config from a config file. + /// + /// The config path is resolved relative to the working directory of this context. + pub fn set_config( + &self, + config_path: S, + key: &str, + value: Option<&str>, + ) -> Result<(), GitError> + where + S: AsRef, + { + let config_path = config_path.as_ref().display().to_string(); + let mut args = vec!["config", "-f", &config_path]; + match value { + Some(v) => { + args.push(key); + args.push(v); + } + None => { + args.push("--unset"); + args.push(key); + } + } + self.run_git_command(&args, false)?; + Ok(()) + } + + /// Remove a config section from a config file. + /// + /// The config path is resolved relative to the working directory of this context. + pub fn remove_config_section(&self, config_path: S, section: &str) -> Result<(), GitError> + where + S: AsRef, + { + let config_path = config_path.as_ref().display().to_string(); + self.run_git_command( + &["config", "-f", &config_path, "--remove-section", section], + false, + )?; + Ok(()) + } + + /// Remove an object from the index and stage the change. The path should be relative from repo top level + pub fn remove_from_index(&self, path: &str) -> Result<(), GitError> { + let top_level_dir = self.top_level_dir()?.display().to_string(); + + // ignore the error because the file might not be in the index + let _ = self.run_git_command(&["-C", &top_level_dir, "rm", path], false); + + let _ = self.run_git_command(&["-C", &top_level_dir, "add", path], false); + Ok(()) + } + + /// Runs `git submodule deinit [-- ]`. Path should be from top level + pub fn submodule_deinit(&self, path: Option<&str>, force: bool) -> Result<(), GitError> { + let top_level_dir = self.top_level_dir()?.display().to_string(); + let mut args = vec!["-C", &top_level_dir, "submodule", "deinit"]; + + if force { + args.push("--force"); + } + + if let Some(path) = path { + args.push("--"); + args.push(path); + } else { + args.push("--all"); + } + self.run_git_command(&args, true)?; + + Ok(()) + } + + /// Runs `git submodule init [-- ]`. Path should be from top level + pub fn submodule_init(&self, path: Option<&str>) -> Result<(), GitError> { + let top_level_dir = self.top_level_dir()?.display().to_string(); + let mut args = vec!["-C", &top_level_dir, "submodule", "init"]; + + if let Some(path) = path { + args.push("--"); + args.push(path); + } + self.run_git_command(&args, true)?; + + Ok(()) + } + + /// Runs `git submodule sync [-- ]`. Path should be from top level + pub fn submodule_sync(&self, path: Option<&str>) -> Result<(), GitError> { + let top_level_dir = self.top_level_dir()?.display().to_string(); + let mut args = vec!["-C", &top_level_dir, "submodule", "sync"]; + + if let Some(path) = path { + args.push("--"); + args.push(path); + } + self.run_git_command(&args, true)?; + + Ok(()) + } + + /// Runs `git submodule update [-- ]`. Path should be from top level + pub fn submodule_update(&self, path: Option<&str>, force: bool) -> Result<(), GitError> { + let top_level_dir = self.top_level_dir()?.display().to_string(); + let mut args = vec!["-C", &top_level_dir, "submodule", "update"]; + + if force { + args.push("--force"); + } + + if let Some(path) = path { + args.push("--"); + args.push(path); + } + self.run_git_command(&args, true)?; + + Ok(()) + } + + /// Runs `git submodule add`. Path should be from top level + pub fn submodule_add( + &self, + url: &str, + path: Option<&str>, + branch: Option<&str>, + name: Option<&str>, + depth: Option, + force: bool, + ) -> Result<(), GitError> { + let top_level_dir = self.top_level_dir()?.display().to_string(); + let mut args = vec!["-C", &top_level_dir, "submodule", "add"]; + if force { + args.push("--force"); + } + if let Some(branch) = branch { + args.push("--branch"); + args.push(branch); + } + if let Some(name) = name { + args.push("--name"); + args.push(name); + } + let depth = depth.map(|x| x.to_string()); + if let Some(depth) = &depth { + args.push("--depth"); + args.push(depth); + } + args.push("--"); + args.push(url); + if let Some(path) = path { + args.push(path); + } + self.run_git_command(&args, true)?; + Ok(()) + } +} + +/// Guard that uses file locking to ensure only one process are manipulating +/// the submodules at a time. +#[derive(Debug)] +pub struct Guard(pub File, pub PathBuf); + +impl Guard { + /// Create a new guard with the given path as the file lock. Will block until + /// the lock can be acquired. + pub fn new

(path: P) -> Result + where + P: AsRef, + { + let path = path.as_ref(); + if path.exists() { + println_warn!("Waiting on file lock. If you are sure no other magoo processes are running, you can remove the lock file `{}`", path.display()); + } + while path.exists() { + println_verbose!("Waiting for lock file..."); + std::thread::sleep(Duration::from_millis(1000)); + } + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(path) + .map_err(|e| GitError::LockFailed(path.display().to_string(), e))?; + file.lock_exclusive() + .map_err(|e| GitError::LockFailed(path.display().to_string(), e))?; + println_verbose!("Acquired lock file `{}`", path.display()); + Ok(Self(file, path.to_path_buf())) + } +} + +impl Drop for Guard { + fn drop(&mut self) { + let path = &self.1.display(); + println_verbose!("Releasing lock file `{path}`"); + if self.0.unlock().is_err() { + println_verbose!("Failed to unlock file `{path}`"); + } + if std::fs::remove_file(&self.1).is_err() { + println_verbose!("Failed to remove file `{path}`"); + } + } +} + +/// Error type for the program +#[derive(Debug, thiserror::Error)] +pub enum GitError { + #[error("operation was successful")] + Success, + + #[error("git is not installed or not in PATH")] + NotInstalled, + + #[error("fail to read `{0}`: {1}")] + CanonicalizeFail(String, std::io::Error), + + #[error("unexpected output: {0}")] + UnexpectedOutput(String), + + #[error("failed to execute `{0}`: {1}: {2}")] + CommandFailed(String, String, std::io::Error), + + #[error("command `{0}` finished with {1}")] + ExitStatus(String, ExitStatus), + + #[error("cannot process config: {0}")] + InvalidConfig(String), + + #[error("cannot process index: {0}")] + InvalidIndex(String), + + #[error("cannot find module `{0}`")] + ModuleNotFound(String), + + #[error("cannot lock `{0}`: {1}")] + LockFailed(String, std::io::Error), + + #[error("fix the issues above and try again.")] + NeedFix, +} + +/// Helper trait to canonicalize a path and return a [`GitError`] if failed +pub trait GitCanonicalize { + fn canonicalize_git(&self) -> Result; +} + +impl GitCanonicalize for S +where + S: AsRef, +{ + fn canonicalize_git(&self) -> Result { + let s = self.as_ref(); + s.canonicalize() + .map_err(|e| GitError::CanonicalizeFail(s.display().to_string(), e)) + } +} + +/// Quote the argument for shell. +pub fn quote_arg(s: &str) -> Cow<'_, str> { + // note that this implementation doesn't work in a few edge cases + // but atm I don't have enough time to thoroughly test it + if s.is_empty() { + Cow::Borrowed("''") + } else if s.contains(' ') || s.contains('\'') { + Cow::Owned(format!("'{s}'")) + } else { + Cow::Borrowed(s) + } +} diff --git a/src/git/context.rs b/src/git/context.rs deleted file mode 100644 index 7849414..0000000 --- a/src/git/context.rs +++ /dev/null @@ -1,755 +0,0 @@ -use std::cell::OnceCell; -use std::collections::BTreeMap; -use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::time::Duration; - -use fs4::FileExt; - -use crate::print::{self, print_progress, println_info, println_verbose, println_warn}; -use crate::submodule::{InGitConfig, InGitModule, InGitmodules, IndexObject, Submodule}; - -use super::{quote_arg, GitCanonicalize, GitError}; - -/// Context for running git commands -/// -/// Some command results are cached to avoid running git commands multiple times. -pub struct GitContext { - /// The absolute path of the working directory to run the git commands - working_dir: PathBuf, - - /// The path to the .git directory - /// - /// This is retrieved from `git rev-parse --git-dir` and cached. - git_dir_cell: OnceCell, - - /// The path to the top level directory - /// - /// This is retrieved from `git rev-parse --show-toplevel` and cached. - top_level_cell: OnceCell, -} - -impl GitContext { - /// Create a new GitContext for running git commands in the given working directory - pub fn try_from(working_dir: S) -> Result - where - S: AsRef, - { - if which::which("git").is_err() { - return Err(GitError::NotInstalled); - } - Ok(Self { - working_dir: working_dir.as_ref().canonicalize_git()?, - git_dir_cell: OnceCell::new(), - top_level_cell: OnceCell::new(), - }) - } - - /// Return a guard that locks the repository until dropped. Other magoo processes cannot access - /// the repository while the guard is alive. - pub fn lock(&self) -> Result { - let git_dir = self.git_dir()?; - let lock_path = git_dir.join("magoo.lock"); - Guard::new(lock_path) - } - - pub fn print_version_info(&self) -> Result<(), GitError> { - println_info!( - "The officially supported git versions are: {}", - super::SUPPORTED_GIT_VERSIONS - ); - println_info!("Your `git --version` is:"); - self.run_git_command(&["--version"], PrintMode::Normal)?; - Ok(()) - } - - /// Get the absolute path to the .git directory - pub fn git_dir(&self) -> Result<&PathBuf, GitError> { - if let Some(git_dir) = self.git_dir_cell.get() { - return Ok(git_dir); - } - - let output = self.run_git_command(&["rev-parse", "--git-dir"], PrintMode::Quiet)?; - let git_dir_path = output.first().ok_or_else(|| { - GitError::UnexpectedOutput("git did not return the .git directory".to_string()) - })?; - let path = self.working_dir.join(git_dir_path).canonicalize_git()?; - - self.git_dir_cell.set(path).unwrap(); - Ok(self.git_dir_cell.get().unwrap()) - } - - /// Get the absolute path to the top level directory - pub fn top_level_dir(&self) -> Result<&PathBuf, GitError> { - if let Some(top_level) = self.top_level_cell.get() { - return Ok(top_level); - } - - let output = self.run_git_command(&["rev-parse", "--show-toplevel"], PrintMode::Quiet)?; - let top_dir_path = output.first().ok_or_else(|| { - GitError::UnexpectedOutput("git did not return the top level directory".to_string()) - })?; - let path = self.working_dir.join(top_dir_path).canonicalize_git()?; - - self.top_level_cell.set(path).unwrap(); - Ok(self.top_level_cell.get().unwrap()) - } - - pub fn describe(&self, commit: &str) -> Option { - self.run_git_command(&["describe", "--all", commit], PrintMode::Quiet) - .ok() - .and_then(|x| x.into_iter().next()) - } - - /// Get the submodule status in the repository. - /// - /// `status_map` gets filled with submodules with names, while `lone_index` gets - /// filled with submodules in the index that are not found anywhere else. - /// - /// If `all` is false, it will not include submodules that are only in the index and in - /// .git/modules - pub fn get_submodule_status( - &self, - status_map: &mut BTreeMap, - lone_index: &mut Vec, - all: bool, - ) -> Result<(), GitError> { - status_map.clear(); - lone_index.clear(); - // read .gitmodules - { - let map = self.read_dot_gitmodules().unwrap_or_default(); - for (name, submodule) in map { - status_map.insert( - name, - Submodule { - in_gitmodules: Some(submodule), - in_config: None, - in_index: None, - in_modules: None, - }, - ); - } - } - // read .git/config - { - let map = self.read_dot_git_config().unwrap_or_default(); - for (name, submodule) in map { - if let Some(s) = status_map.get_mut(&name) { - s.in_config = Some(submodule); - } else { - status_map.insert( - name, - Submodule { - in_gitmodules: None, - in_config: Some(submodule), - in_index: None, - in_modules: None, - }, - ); - } - } - } - // read .git/modules - if all { - let map = self.find_all_git_modules().unwrap_or_default(); - for (name, module) in map { - if let Some(s) = status_map.get_mut(&name) { - s.in_modules = Some(module); - } else { - status_map.insert( - name, - Submodule { - in_gitmodules: None, - in_config: None, - in_index: None, - in_modules: Some(module), - }, - ); - } - } - } else { - for (name, submodule) in status_map.iter_mut() { - if let Ok(module) = self.read_git_module(name) { - submodule.in_modules = Some(module); - } - } - }; - let mut index = self.read_submodules_in_index().unwrap_or_default(); - - for submodule in status_map.values_mut() { - let path = match submodule.path() { - Some(path) => path, - None => continue, - }; - if let Some(index_obj) = index.remove(path) { - submodule.in_index = Some(index_obj); - } - } - - if all { - for (_, index_obj) in index { - lone_index.push(index_obj); - } - } - - Ok(()) - } - - /// Read the .gitmodules file from this repo and return a map of submodule names to - /// [`GitmodulesSubmodule`] - /// - /// Returns an error if any git command fails. Use `.unwrap_or_default()` to get an empty map. - pub fn read_dot_gitmodules(&self) -> Result, GitError> { - let top_level_dir = self.top_level_dir()?; - let dot_gitmodules_path = top_level_dir.join(".gitmodules"); - - let config_entries = - self.read_submodule_from_config(&dot_gitmodules_path.display().to_string())?; - - let mut submodules = BTreeMap::new(); - - for (key, value) in config_entries { - if let Some(x) = key.strip_suffix(".path") { - let entry = submodules - .entry(x.to_string()) - .or_insert_with(|| InGitmodules::with_name(x)); - entry.path = Some(value); - } else if let Some(x) = key.strip_suffix(".url") { - let entry = submodules - .entry(x.to_string()) - .or_insert_with(|| InGitmodules::with_name(x)); - entry.url = Some(value); - } else if let Some(x) = key.strip_suffix(".branch") { - let entry = submodules - .entry(x.to_string()) - .or_insert_with(|| InGitmodules::with_name(x)); - entry.branch = Some(value); - } - } - - println_verbose!( - "Found {} submodules in .gitmodules: {:?}", - submodules.len(), - submodules.keys().collect::>() - ); - Ok(submodules) - } - - /// Read the .git/config file and return a map of submodule names to [`GitConfigSubmodule`]. - /// - /// Returns an error if any git command fails. Use `.unwrap_or_default()` to get an empty map. - pub fn read_dot_git_config(&self) -> Result, GitError> { - let git_dir = self.git_dir()?; - let dot_git_config_path = git_dir.join("config"); - - let config_entries = - self.read_submodule_from_config(&dot_git_config_path.display().to_string())?; - - let mut submodules = BTreeMap::new(); - - for (key, value) in config_entries { - if let Some(x) = key.strip_suffix(".url") { - let entry = submodules - .entry(x.to_string()) - .or_insert_with(|| InGitConfig::with_name(x)); - entry.url = Some(value); - } - } - - println_verbose!( - "Found {} submodules in .git/config: {:?}", - submodules.len(), - submodules.keys().collect::>() - ); - Ok(submodules) - } - - /// Use `git ls-files` to list submodules stored in the index. - /// - /// Returns a map of path to submodule [`IndexObject`]. - pub fn read_submodules_in_index(&self) -> Result, GitError> { - let top_level_dir = self.top_level_dir()?; - let output = self.run_git_command( - &[ - "-C", - &top_level_dir.display().to_string(), - "ls-files", - r#"--format=%(objectmode) %(objectname) %(path)"#, - ], - PrintMode::Progress, - )?; - let mut submodules = BTreeMap::new(); - - for line in output { - // mode 160000 is submodule - let line = match line.strip_prefix("160000 ") { - Some(line) => line, - None => { - continue; - } - }; - println_verbose!("Found submodule in index: {}", line); - let mut parts = line.splitn(2, ' '); - let sha = parts.next().ok_or_else(|| { - GitError::InvalidIndex("missing commit hash in output".to_string()) - })?; - let path = parts - .next() - .ok_or_else(|| GitError::InvalidIndex("missing path in output".to_string()))?; - submodules.insert( - path.to_string(), - IndexObject { - sha: sha.to_string(), - path: path.to_string(), - }, - ); - } - - println_verbose!( - "Found {} submodules in index: {:?}", - submodules.len(), - submodules.keys().collect::>() - ); - Ok(submodules) - } - - /// Read .git/modules and find all entries. - pub fn find_all_git_modules(&self) -> Result, GitError> { - let mut modules = BTreeMap::new(); - let git_dir = self.git_dir()?; - let module_dir = git_dir.join("modules"); - if !module_dir.exists() { - println_verbose!(".git/modules does not exist"); - } else { - self.find_git_modules_recursively(None, &module_dir, &mut modules); - } - Ok(modules) - } - - fn find_git_modules_recursively( - &self, - name: Option<&str>, - dir_path: &Path, - modules: &mut BTreeMap, - ) { - println_verbose!("Scanning for git modules in `{}`", dir_path.display()); - let config_path = dir_path.join("config"); - if config_path.is_file() { - if let Some(name) = name { - // dir_path is a git module - match self.read_git_module(name) { - Err(e) => { - println_verbose!("Failed to read git module `{name}`: {e}"); - } - Ok(module) => { - println_verbose!("Found git module `{name}`"); - modules.insert(name.to_string(), module); - } - } - } - } else { - // dir_path is not a module, recurse - let dir = match dir_path.read_dir() { - Err(e) => { - println_verbose!("Failed to read directory `{}`: {e}", dir_path.display()); - return; - } - Ok(dir) => dir, - }; - for entry in dir { - let entry = match entry { - Err(e) => { - println_verbose!( - "Failed to read directory entry in `{}`: {e}", - dir_path.display() - ); - continue; - } - Ok(entry) => entry, - }; - let full_path = entry.path(); - if full_path.is_dir() { - let entry_file_name = entry.file_name(); - let entry_name_utf8 = match entry_file_name.to_str() { - None => { - println_verbose!( - "File name is not unicode: `{}`", - entry_file_name.to_string_lossy() - ); - continue; - } - Some(name) => name, - }; - let next_name = match name { - Some(name) => format!("{name}/{entry_name_utf8}"), - None => entry_name_utf8.to_string(), - }; - self.find_git_modules_recursively(Some(&next_name), &full_path, modules); - } - } - } - } - - /// Read .git/modules/ and return a [`GitModule`] - pub fn read_git_module(&self, name: &str) -> Result { - let git_dir = self.git_dir()?; - let module_dir = git_dir.join("modules").join(name); - if !module_dir.exists() { - println_verbose!("Module `{name}` not found in .git/modules"); - return Err(GitError::ModuleNotFound(name.to_string())); - } - - let config_path = module_dir.join("config"); - let worktree = self - .run_git_command( - &[ - "config", - "-f", - &config_path.display().to_string(), - "--get", - "core.worktree", - ], - PrintMode::Quiet, - ) - .map(|x| x.into_iter().next()) - .unwrap_or_default(); - - match worktree { - None => { - Ok(InGitModule { - name: name.to_string(), - worktree: None, - head_sha: None, - git_dir: None, - // describe: None, - }) - } - Some(worktree) => { - let path = module_dir.join(&worktree); - let sub_git = match Self::try_from(path).ok() { - Some(sub_git) => sub_git, - None => { - return Ok(InGitModule { - name: name.to_string(), - worktree: Some(worktree), - head_sha: None, - git_dir: None, - // describe: None, - }); - } - }; - let head_sha = sub_git - .run_git_command(&["rev-parse", "HEAD"], PrintMode::Quiet) - .ok() - .and_then(|x| x.into_iter().next()); - let git_dir = sub_git - .run_git_command(&["rev-parse", "--git-dir"], PrintMode::Quiet) - .ok() - .and_then(|x| x.into_iter().next()); - // let describe = head_sha.as_ref().and_then(|head_sha| { - // sub_git.run_git_command(&["describe", "--all", head_sha], PrintMode::Quiet) - // .ok() - // .and_then(|x|x.into_iter().next()) - // }); - - Ok(InGitModule { - name: name.to_string(), - worktree: Some(worktree), - head_sha, - git_dir, - // describe, - }) - } - } - } - - /// Read the git config and return key-value pairs that starts with "submodule.". This prefix is - /// removed for the returned keys. - fn read_submodule_from_config( - &self, - config_path: &str, - ) -> Result, GitError> { - let name_and_values = self.run_git_command( - &["config", "-f", config_path, "--get-regexp", "submodule"], - PrintMode::Quiet, - )?; - let name_only = self.run_git_command( - &[ - "config", - "-f", - config_path, - "--name-only", - "--get-regexp", - "submodule", - ], - PrintMode::Quiet, - )?; - - let mut name_values = Vec::new(); - for (name, name_and_value) in name_only.iter().zip(name_and_values.iter()) { - match name_and_value.strip_prefix(name) { - Some(value) => { - let name = match name.trim().strip_prefix("submodule.") { - Some(name) => name, - None => { - continue; - } - }; - let value = value.trim(); - println_verbose!("Found submodule config: {} => {}", name, value); - name_values.push((name.to_string(), value.to_string())); - } - None => { - return Err(GitError::InvalidConfig( - "unexpected config key mismatch in git output.".to_string(), - )); - } - } - } - - Ok(name_values) - } - - /// Set or remove a config from a config file. The config path is resolved relative to - /// the working directory of this context. - pub fn set_config( - &self, - config_path: S, - key: &str, - value: Option<&str>, - ) -> Result<(), GitError> - where - S: AsRef, - { - let config_path = config_path.as_ref().display().to_string(); - match value { - Some(v) => { - self.run_git_command(&["config", "-f", &config_path, key, v], PrintMode::Quiet)?; - } - None => { - self.run_git_command( - &["config", "-f", &config_path, "--unset", key], - PrintMode::Quiet, - )?; - } - } - Ok(()) - } - - /// Remove a config section from a config file. The config path is resolved relative to - /// the working directory of this context. - pub fn remove_config_section(&self, config_path: S, section: &str) -> Result<(), GitError> - where - S: AsRef, - { - let config_path = config_path.as_ref().display().to_string(); - self.run_git_command( - &["config", "-f", &config_path, "--remove-section", section], - PrintMode::Quiet, - )?; - Ok(()) - } - - /// Remove an object from the index and stage the change. The path should be relative from repo top level - pub fn remove_from_index(&self, path: &str) -> Result<(), GitError> { - let top_level_dir = self.top_level_dir()?; - - self.run_git_command( - &["-C", &top_level_dir.display().to_string(), "rm", path], - PrintMode::Quiet, - )?; - self.run_git_command( - &["-C", &top_level_dir.display().to_string(), "add", path], - PrintMode::Quiet, - )?; - Ok(()) - } - - /// Runs `git submodule deinit [-- ]` - pub fn submodule_deinit(&self, path: Option<&str>) -> Result<(), GitError> { - let top_level_dir = self.top_level_dir()?; - match path { - Some(path) => { - self.run_git_command( - &[ - "-C", - &top_level_dir.display().to_string(), - "submodule", - "deinit", - "--", - path, - ], - PrintMode::Normal, - )?; - } - None => { - self.run_git_command( - &[ - "-C", - &top_level_dir.display().to_string(), - "submodule", - "deinit", - ], - PrintMode::Normal, - )?; - } - } - Ok(()) - } - - /// Run the git command from self's working directory - /// - /// The output of the command will be returned as a vector of lines. - pub fn run_git_command( - &self, - args: &[&str], - print: PrintMode, - ) -> Result, GitError> { - let args_str = args - .iter() - .map(|x| { - if x.contains(' ') { - format!("'{x}'") - } else { - x.to_string() - } - }) - .collect::>() - .join(" "); - let command = format!("git {args_str}"); - println_verbose!("Running `{command}`"); - - let mut child = Command::new("git") - .args(args) - .current_dir(&self.working_dir) - .stdout(Stdio::piped()) - .stderr(if print == PrintMode::Quiet { - Stdio::null() - } else { - Stdio::inherit() - }) - .spawn() - .map_err(|e| { - GitError::CommandFailed(command.clone(), "failed to spawn process".to_string(), e) - })?; - - let mut output = Vec::new(); - if let Some(stdout) = child.stdout.take() { - let reader = BufReader::new(stdout); - for line in reader.lines() { - let line = line.map_err(|e| { - GitError::CommandFailed(command.clone(), "failed to read output".to_string(), e) - })?; - match print { - PrintMode::Normal => { - println!("{}", line); - } - PrintMode::Progress => { - print_progress!("{}", line); - } - PrintMode::Quiet => {} - } - output.push(line); - } - } - let status = child.wait().map_err(|e| { - GitError::CommandFailed( - command.clone(), - "command did not finish normally".to_string(), - e, - ) - })?; - if print == PrintMode::Progress { - print::progress_done(); - } - println_verbose!("Git command finished: {}", status); - if status.success() { - Ok(output) - } else { - Err(GitError::ExitStatus(command, status)) - } - } - - /// Get the part in `git -C ...` to run the command in the top level directory - pub fn get_top_level_switch(&self) -> Result, GitError> { - let top_level_dir = self.top_level_dir()?; - - let command = match Path::new(".").canonicalize() { - Ok(cwd) => { - if &cwd == top_level_dir { - None - } else { - let path = pathdiff::diff_paths(top_level_dir, &cwd) - .unwrap_or(top_level_dir.to_path_buf()); - let diff = path.display().to_string(); - Some(quote_arg(&diff).to_string()) - } - } - Err(_) => { - let top_level = top_level_dir.display().to_string(); - Some(quote_arg(&top_level).to_string()) - } - }; - - Ok(command) - } -} - -/// Print mode for git commands -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PrintMode { - /// Don't print git output - Quiet, - /// Print git output as progress (only show last line) - Progress, - /// Print git output as normal - Normal, -} - -/// Guard that uses file locking to ensure only one process are manipulating -/// the submodules at a time. -pub struct Guard(pub File, pub PathBuf); - -impl Guard { - /// Create a new guard with the given path as the file lock. Will block until - /// the lock can be acquired. - pub fn new

(path: P) -> Result - where - P: AsRef, - { - let path = path.as_ref(); - if path.exists() { - println_warn!("Waiting on file lock. If you are sure no other magoo processes are running, you can remove the lock file `{}`", path.display()); - } - while path.exists() { - println_verbose!("Waiting for lock file..."); - std::thread::sleep(Duration::from_millis(1000)); - } - let file = std::fs::OpenOptions::new() - .read(true) - .write(true) - .create(true) - .open(path) - .map_err(|e| GitError::LockFailed(path.display().to_string(), e))?; - file.lock_exclusive() - .map_err(|e| GitError::LockFailed(path.display().to_string(), e))?; - println_verbose!("Acquired lock file `{}`", path.display()); - Ok(Self(file, path.to_path_buf())) - } -} - -impl Drop for Guard { - fn drop(&mut self) { - let path = &self.1.display(); - println_verbose!("Releasing lock file `{path}`"); - if self.0.unlock().is_err() { - println_verbose!("Failed to unlock file `{path}`"); - } - if std::fs::remove_file(&self.1).is_err() { - println_verbose!("Failed to remove file `{path}`"); - } - } -} diff --git a/src/git/mod.rs b/src/git/mod.rs deleted file mode 100644 index a34333b..0000000 --- a/src/git/mod.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Integration with git commands -use std::borrow::Cow; -use std::path::{Path, PathBuf}; -use std::process::ExitStatus; - -mod context; -pub use context::*; -/// The semver notation of the officially supported git versions -/// -/// The version is not checked at run time, since unsupported versions might work fine. -pub const SUPPORTED_GIT_VERSIONS: &str = ">=2.35.0"; - -#[derive(Debug, thiserror::Error)] -pub enum GitError { - #[error("git is not installed or not in PATH")] - NotInstalled, - - #[error("fail to read `{0}`: {1}")] - CanonicalizeFail(String, std::io::Error), - - #[error("unexpected output: {0}")] - UnexpectedOutput(String), - - #[error("failed to execute `{0}`: {1}: {2}")] - CommandFailed(String, String, std::io::Error), - - #[error("command `{0}` finished with {1}")] - ExitStatus(String, ExitStatus), - - #[error("cannot process config: {0}")] - InvalidConfig(String), - - #[error("cannot process index: {0}")] - InvalidIndex(String), - - #[error("cannot find module `{0}`")] - ModuleNotFound(String), - - #[error("cannot lock `{0}`: {1}")] - LockFailed(String, std::io::Error), -} - -/// Helper trait to canonicalize a path and return a [`GitError`] if failed -pub trait GitCanonicalize { - fn canonicalize_git(&self) -> Result; -} - -impl GitCanonicalize for S -where - S: AsRef, -{ - fn canonicalize_git(&self) -> Result { - let s = self.as_ref(); - s.canonicalize() - .map_err(|e| GitError::CanonicalizeFail(s.display().to_string(), e)) - } -} - -/// Quote the argument for shell. -pub fn quote_arg(s: &str) -> Cow<'_, str> { - // note that this implementation doesn't work in a few edge cases - // but atm I don't have enough time to thoroughly test it - if s.is_empty() { - Cow::Borrowed("''") - } else if s.contains(' ') || s.contains('\'') { - Cow::Owned(format!("'{s}'")) - } else { - Cow::Borrowed(s) - } -} diff --git a/src/lib.rs b/src/lib.rs index 47fbe9b..9b8701f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -68,16 +68,16 @@ //! reference. //! -use std::collections::BTreeMap; - -// mod error; mod git; pub use git::SUPPORTED_GIT_VERSIONS; use git::{GitContext, GitError}; mod print; +mod status; mod submodule; -use submodule::Submodule; +use status::Status; + +use crate::print::println_verbose; /// The main entry point for the library #[derive(Debug, Clone, PartialEq)] @@ -115,7 +115,7 @@ pub enum Command { /// /// Installs dependencies if no arguments are provided. /// Otherwise, adds the provided dependency as a git submodule. - Install(AddCommand), + Install(InstallCommand), /// Updates all dependencies or the specified dependency. /// /// Dependencies will be updated to the branch (specified when adding the dependency) from the @@ -129,8 +129,8 @@ impl Command { pub fn set_print_options(&self) { match self { Command::Status(cmd) => cmd.set_print_options(), + Command::Install(cmd) => cmd.set_print_options(), _ => todo!(), - // Command::Install(cmd) => cmd.set_print_options(), // Command::Update(cmd) => cmd.set_print_options(), // Command::Remove(cmd) => cmd.set_print_options(), } @@ -140,8 +140,10 @@ impl Command { Command::Status(cmd) => { cmd.run(dir)?; } + Command::Install(cmd) => { + cmd.run(dir)?; + } _ => todo!(), - // Command::Install(cmd) => cmd.run(dir), // Command::Update(cmd) => cmd.run(dir), // Command::Remove(cmd) => cmd.run(dir), } @@ -183,44 +185,29 @@ pub struct StatusCommand { impl StatusCommand { pub fn set_print_options(&self) { - print::set_options(self.options.verbose, self.options.quiet, None); + self.options.apply(); } - pub fn run(&self, dir: &str) -> Result, GitError> { + pub fn run(&self, dir: &str) -> Result { let context = GitContext::try_from(dir)?; let _guard = context.lock()?; if self.git { context.print_version_info()?; - return Ok(vec![]); + return Ok(Status::default()); } - let mut status_map = BTreeMap::new(); - let mut index = Vec::new(); - - context.get_submodule_status(&mut status_map, &mut index, self.all)?; - - let mut status = status_map.into_values().collect::>(); - if self.all { - index.into_iter().for_each(|v| { - status.push(Submodule { - in_gitmodules: None, - in_config: None, - in_index: Some(v), - in_modules: None, - }) - }); + let mut status = Status::read_from(&context, self.all)?; + let mut flat_status = status.flattened_mut(); + if flat_status.is_empty() { + println!("No submodules found"); + return Ok(status); } if self.fix { - for submodule in &mut status { + for submodule in flat_status.iter_mut() { submodule.fix(&context)?; } return Ok(status); } - if status.is_empty() { - println!("No submodules found"); - return Ok(vec![]); - } - let dir_switch = if dir == "." { "".to_string() } else { @@ -229,7 +216,7 @@ impl StatusCommand { let all_switch = if self.all { " --all" } else { "" }; - for submodule in &status { + for submodule in &flat_status { submodule.print(&context, &dir_switch, all_switch)?; } Ok(status) @@ -268,15 +255,19 @@ impl PrintOptions { /// The `add` command #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "cli", derive(clap::Parser))] -pub struct AddCommand { +pub struct InstallCommand { /// URL of the git repository to add /// - /// See the `add` command of https://git-scm.com/docs/git-submodule for what formats are + /// See the `add` command of for what formats are /// supported. - pub url: String, + pub url: Option, /// Local path to clone the git submodule to - pub path: String, + /// + /// Unlike the path specified with `git submodule add`, this path should be relative from + /// the top level (root) of the git repository. + #[cfg_attr(feature = "cli", arg(requires("url")))] + pub path: Option, /// Branch to checkout and track /// @@ -284,24 +275,66 @@ pub struct AddCommand { /// If not specified, the behavior is the same as `git submodule add` without `--branch` /// (`HEAD` is used) #[cfg_attr(feature = "cli", clap(long, short))] + #[cfg_attr(feature = "cli", arg(requires("url")))] pub branch: Option, /// Name of the submodule /// /// If not specified, the name of the submodule is the same as the path. #[cfg_attr(feature = "cli", clap(long))] + #[cfg_attr(feature = "cli", arg(requires("url")))] pub name: Option, /// Depth to clone the submodule #[cfg_attr(feature = "cli", clap(long))] + #[cfg_attr(feature = "cli", arg(requires("url")))] pub depth: Option, /// Whether to force the submodule to be added /// - /// This is the same as the `--force` flag of `git submodule add`. The submodule will be - /// added even if one with the same name or path already existed. + /// This will pass the `--force` flag to `git submodule add` and `git submodule update`. #[cfg_attr(feature = "cli", clap(long, short))] pub force: bool, + + #[cfg_attr(feature = "cli", clap(flatten))] + pub options: PrintOptions, +} + +impl InstallCommand { + pub fn set_print_options(&self) { + self.options.apply(); + } + pub fn run(&self, dir: &str) -> Result<(), GitError> { + let context = GitContext::try_from(dir)?; + let _guard = context.lock()?; + + let mut status = Status::read_from(&context, true)?; + for submodule in status.flattened_mut() { + submodule.fix(&context)?; + } + + match &self.url { + Some(url) => { + println_verbose!("Adding submodule from url: {url}"); + context.submodule_add( + url, + self.path.as_deref(), + self.branch.as_deref(), + self.name.as_deref(), + self.depth.as_ref().copied(), + self.force, + )?; + } + None => { + println_verbose!("Installing submodules"); + context.submodule_init(None)?; + context.submodule_sync(None)?; + context.submodule_update(None, self.force)?; + } + } + + Ok(()) + } } /// The `update` command diff --git a/src/main.rs b/src/main.rs index c4b8dce..788da04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use std::process::exit; + use clap::Parser; use magoo::Magoo; @@ -5,7 +7,8 @@ fn main() { let cli = Magoo::parse(); cli.set_print_options(); if let Err(e) = cli.run() { - eprintln!("magoo: fatal:"); - eprintln!(" {e}"); + println!("magoo: fatal:"); + println!(" {e}"); + exit(1) } } diff --git a/src/print.rs b/src/print.rs index 77744be..012b717 100644 --- a/src/print.rs +++ b/src/print.rs @@ -1,9 +1,9 @@ //! Printing utilities -use std::io::{IsTerminal, Write}; +use std::io::IsTerminal; use std::process::Command; -use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; +use termcolor::{Color, ColorChoice, ColorSpec, StandardStream}; static mut VERBOSE: bool = false; static mut QUIET: bool = true; @@ -66,7 +66,7 @@ pub fn warn_color() -> ColorSpec { x } -pub fn progress_color() -> ColorSpec { +pub fn hint_color() -> ColorSpec { let mut x = ColorSpec::new(); x.set_fg(Some(Color::Yellow)).set_intense(true); x @@ -158,75 +158,37 @@ macro_rules! print_error { #[allow(unused)] pub(crate) use print_error; -/// Print process -macro_rules! print_progress { - ($($args:tt)*) => { - if !$crate::print::is_quiet() { - use std::io::{Write, IsTerminal}; - use termcolor::WriteColor; - if std::io::stdout().is_terminal() { - let mut stdout = $crate::print::stdout(); - let _ = stdout.set_color(&$crate::print::progress_color()); - if let Some(cols) = termsize::get().map(|size| size.cols as usize) { - let _ = write!(&mut stdout, "{:width$}\r", "", width = cols - 1); - } - let _ = write!(&mut stdout, $($args)*); - let _ = write!(&mut stdout, "\r"); - let _ = std::io::stdout().flush(); - } else { - println!($($args)*); - } - } - }; -} -pub(crate) use print_progress; - -/// Clear the progress line and reset the color -pub fn progress_done() { - if is_quiet() { - return; - } - if std::io::stdout().is_terminal() { - let mut stdout = stdout(); - let _ = stdout.reset(); - if let Some(cols) = termsize::get().map(|size| size.cols as usize) { - print!("{:width$}\r", "", width = cols - 1); - } - let _ = std::io::stdout().flush(); - } -} - -/// Print using dimmed color -macro_rules! println_dimmed { +/// Print using hint color +macro_rules! println_hint { ($($args:tt)*) => { if !$crate::print::is_quiet() { use std::io::Write; use termcolor::WriteColor; let mut stdout = $crate::print::stdout(); - let _ = stdout.set_color(&$crate::print::verbose_color()); + let _ = stdout.set_color(&$crate::print::hint_color()); let _ = writeln!(&mut stdout, $($args)*); let _ = stdout.reset(); } }; } -pub(crate) use println_dimmed; +pub(crate) use println_hint; -/// Print using dimmed color without a new line +/// Print using hint color without a new line #[allow(unused_macros)] -macro_rules! print_dimmed { +macro_rules! print_hint { ($($args:tt)*) => { if !$crate::print::is_quiet() { use std::io::Write; use termcolor::WriteColor; let mut stdout = $crate::print::stdout(); - let _ = stdout.set_color(&$crate::print::verbose_color()); + let _ = stdout.set_color(&$crate::print::hint_color()); let _ = write!(&mut stdout, $($args)*); let _ = stdout.reset(); } }; } #[allow(unused)] -pub(crate) use print_dimmed; +pub(crate) use print_hint; /// Print message if verbose is true macro_rules! println_verbose { diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..b6c0a40 --- /dev/null +++ b/src/status.rs @@ -0,0 +1,402 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use crate::git::{GitContext, GitError}; +use crate::print::println_verbose; +use crate::submodule::*; + +/// Data returned from [`GitContext::submodule_status`] +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Status { + /// The submodule status map from name to [`Submodule`] + pub modules: BTreeMap, + /// The submodules that only exist in the index (thus don't have a name, only a path and a + /// SHA-1) + pub nameless: Vec, +} + +macro_rules! insert_with_name { + ($modules:expr, $name:ident) => {{ + let m = $modules; + if let Some(s) = m.get_mut($name) { + s.in_gitmodules.as_mut().unwrap() + } else { + m.insert( + $name.to_string(), + Submodule { + in_gitmodules: Some(InGitmodules::with_name($name)), + in_config: None, + in_index: None, + in_modules: None, + }, + ); + m.get_mut($name).unwrap().in_gitmodules.as_mut().unwrap() + } + }}; +} + +impl Status { + /// Return a flattened view of all the submodules + /// + /// If the status was created with the `--all` flag, it will also include the nameless + /// submodules + pub fn flattened(&self) -> Vec<&Submodule> { + let mut modules = self.modules.values().collect::>(); + for index_obj in &self.nameless { + modules.push(index_obj); + } + modules + } + + /// Return a flattened view of all the submodules + /// + /// If the status was created with the `--all` flag, it will also include the nameless + /// submodules + pub fn flattened_mut(&mut self) -> Vec<&mut Submodule> { + let mut modules = self.modules.values_mut().collect::>(); + for index_obj in self.nameless.iter_mut() { + modules.push(index_obj); + } + modules + } + + /// Flattens the submodules into a vector of [`Submodule`] + /// + /// If the status was created with the `--all` flag, it will also include the nameless + /// submodules + pub fn into_flattened(self) -> Vec { + let mut modules = self.modules.into_values().collect::>(); + for index_obj in self.nameless { + modules.push(index_obj); + } + modules + } + + /// Get a view of the submodules that only exist in the index + pub fn nameless_objects(&self) -> Vec<&IndexObject> { + self.nameless + .iter() + .map(|s| s.in_index.as_ref().unwrap()) + .collect() + } + + pub fn is_healthy(&self, context: &GitContext) -> Result { + for submodule in self.flattened() { + if !submodule.is_healthy(context)? { + return Ok(false); + } + } + Ok(true) + } + + /// Get the submodule status in the repository. + /// + /// If `all` is false, it will not include submodules that are only in the index and in + /// `.git/modules` + pub fn read_from(context: &GitContext, all: bool) -> Result { + let mut status = Self::default(); + status.read_dot_gitmodules(context)?; + status.read_dot_git_config(context)?; + // read .git/modules + if all { + status.find_all_git_modules(context)?; + } else { + for (name, submodule) in status.modules.iter_mut() { + if let Ok(module) = Self::read_git_module(name, context) { + submodule.in_modules = Some(module); + } + } + }; + status.read_submodules_in_index(context, all)?; + + Ok(status) + } + + /// Read the `.gitmodules` data into self + fn read_dot_gitmodules(&mut self, context: &GitContext) -> Result<(), GitError> { + let top_level_dir = context.top_level_dir()?; + let dot_gitmodules_path = top_level_dir.join(".gitmodules"); + + let config_entries = + Self::read_submodule_from_config(context, &dot_gitmodules_path.display().to_string())?; + + for (key, value) in config_entries { + let name = if let Some(name) = key.strip_suffix(".path") { + insert_with_name!(&mut self.modules, name).path = Some(value); + name + } else if let Some(name) = key.strip_suffix(".url") { + insert_with_name!(&mut self.modules, name).url = Some(value); + name + } else if let Some(name) = key.strip_suffix(".branch") { + insert_with_name!(&mut self.modules, name).branch = Some(value); + name + } else { + continue; + }; + + println_verbose!("Found submodule in .gitmodules: {name}"); + } + Ok(()) + } + + /// Read the `.git/config` data into self + fn read_dot_git_config(&mut self, context: &GitContext) -> Result<(), GitError> { + let git_dir = context.git_dir()?; + let dot_git_config_path = git_dir.join("config"); + + let config_entries = match Self::read_submodule_from_config( + context, + &dot_git_config_path.display().to_string(), + ) { + Ok(entries) => entries, + Err(e) => { + println_verbose!("Git error when reading submodules from .git/config, assuming no submodules: {e}"); + return Ok(()); + } + }; + + for (key, value) in config_entries { + if let Some(name) = key.strip_suffix(".url") { + println_verbose!("Found submodule in .git/config: {}", name); + let submodule = InGitConfig { + name: name.to_string(), + url: value, + }; + + if let Some(s) = self.modules.get_mut(name) { + s.in_config = Some(submodule); + } else { + self.modules.insert( + name.to_string(), + Submodule { + in_gitmodules: None, + in_config: Some(submodule), + in_index: None, + in_modules: None, + }, + ); + } + } + } + + Ok(()) + } + + /// Read the git config and return key-value pairs that starts with "submodule.". This prefix is + /// removed for the returned keys. + fn read_submodule_from_config( + context: &GitContext, + config_path: &str, + ) -> Result, GitError> { + let name_values = context.get_config_regexp(config_path, "submodule")?; + let name_values = name_values + .into_iter() + .filter_map(|(name, value)| { + let name = name.strip_prefix("submodule.")?; + println_verbose!("Found submodule config: {} => {}", name, value); + Some((name.to_string(), value)) + }) + .collect::>(); + + Ok(name_values) + } + + /// Read .git/modules and find all entries and put them in self + fn find_all_git_modules(&mut self, context: &GitContext) -> Result<(), GitError> { + let git_dir = context.git_dir()?; + let module_dir = git_dir.join("modules"); + if !module_dir.exists() { + println_verbose!(".git/modules does not exist"); + } else { + self.find_git_modules_recursively(context, None, &module_dir); + } + Ok(()) + } + + fn find_git_modules_recursively( + &mut self, + context: &GitContext, + name: Option<&str>, + dir_path: &Path, + ) { + println_verbose!("Scanning for git modules in `{}`", dir_path.display()); + let config_path = dir_path.join("config"); + if config_path.is_file() { + if let Some(name) = name { + // dir_path is a git module + match Self::read_git_module(name, context) { + Err(e) => { + println_verbose!("Failed to read git module `{name}`: {e}"); + } + Ok(module) => { + println_verbose!("Found git module `{name}`"); + if let Some(s) = self.modules.get_mut(name) { + s.in_modules = Some(module); + } else { + self.modules.insert( + name.to_string(), + Submodule { + in_gitmodules: None, + in_config: None, + in_index: None, + in_modules: Some(module), + }, + ); + } + } + } + } + } else { + // dir_path is not a module, recurse + let dir = match dir_path.read_dir() { + Err(e) => { + println_verbose!("Failed to read directory `{}`: {e}", dir_path.display()); + return; + } + Ok(dir) => dir, + }; + for entry in dir { + let entry = match entry { + Err(e) => { + println_verbose!( + "Failed to read directory entry in `{}`: {e}", + dir_path.display() + ); + continue; + } + Ok(entry) => entry, + }; + let full_path = entry.path(); + if full_path.is_dir() { + let entry_file_name = entry.file_name(); + let entry_name_utf8 = match entry_file_name.to_str() { + None => { + println_verbose!( + "File name is not unicode: `{}`", + entry_file_name.to_string_lossy() + ); + continue; + } + Some(name) => name, + }; + let next_name = match name { + Some(name) => format!("{name}/{entry_name_utf8}"), + None => entry_name_utf8.to_string(), + }; + self.find_git_modules_recursively(context, Some(&next_name), &full_path); + } + } + } + } + + /// Read `.git/modules/` + fn read_git_module(name: &str, context: &GitContext) -> Result { + let git_dir = context.git_dir()?; + let module_dir = git_dir.join("modules").join(name); + if !module_dir.exists() { + println_verbose!("Module `{name}` not found in .git/modules"); + return Err(GitError::ModuleNotFound(name.to_string())); + } + + let config_path = module_dir.join("config"); + let worktree = context + .get_config(config_path, "core.worktree") + .unwrap_or_default(); + + match worktree { + None => Ok(InGitModule { + name: name.to_string(), + worktree: None, + head_sha: None, + git_dir: None, + }), + Some(worktree) => { + let path = module_dir.join(&worktree); + let sub_git = match GitContext::try_from(path).ok() { + Some(sub_git) => sub_git, + None => { + return Ok(InGitModule { + name: name.to_string(), + worktree: Some(worktree), + head_sha: None, + git_dir: None, + }); + } + }; + let head_sha = sub_git.head().unwrap_or_default(); + let git_dir = sub_git.git_dir_raw().unwrap_or_default(); + + Ok(InGitModule { + name: name.to_string(), + worktree: Some(worktree), + head_sha, + git_dir, + }) + } + } + } + + /// Use `git ls-files` to list submodules stored in the index into self + fn read_submodules_in_index( + &mut self, + context: &GitContext, + all: bool, + ) -> Result<(), GitError> { + let index_list = context.ls_files(&[r#"--format=%(objectmode) %(objectname) %(path)"#])?; + + let mut path_to_index_object = BTreeMap::new(); + + for line in index_list { + // mode 160000 is submodule + let line = match line.strip_prefix("160000 ") { + Some(line) => line, + None => { + continue; + } + }; + println_verbose!("Found submodule in index: {}", line); + let mut parts = line.splitn(2, ' '); + let sha = parts.next().ok_or_else(|| { + GitError::InvalidIndex("missing commit hash in output".to_string()) + })?; + let path = parts + .next() + .ok_or_else(|| GitError::InvalidIndex("missing path in output".to_string()))?; + + path_to_index_object.insert( + path.to_string(), + IndexObject { + sha: sha.to_string(), + path: path.to_string(), + }, + ); + } + + for submodule in self.modules.values_mut() { + let path = match submodule.path() { + Some(path) => path, + None => continue, + }; + if let Some(index_obj) = path_to_index_object.remove(path) { + println_verbose!( + "Connect index path `{}` to submodule `{}`", + path, + submodule.name().unwrap_or_default() + ); + submodule.in_index = Some(index_obj); + } + } + + if all { + for index_obj in path_to_index_object.into_values() { + self.nameless.push(Submodule { + in_gitmodules: None, + in_config: None, + in_index: Some(index_obj), + in_modules: None, + }); + } + } + Ok(()) + } +} diff --git a/src/submodule.rs b/src/submodule.rs index 4f7f7c3..5382aa2 100644 --- a/src/submodule.rs +++ b/src/submodule.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use crate::git::{quote_arg, GitCanonicalize, GitContext, GitError}; -use crate::print::{println_dimmed, println_error, println_info, println_verbose, println_warn}; +use crate::print::{println_error, println_hint, println_info, println_verbose, println_warn}; /// Collection of data of a submodule with the same name as identifier #[derive(Debug, Default, Clone, PartialEq)] @@ -59,16 +59,6 @@ impl Submodule { None } - /// Get the paths stored in different places are consistent - pub fn are_paths_consistent(&self) -> bool { - todo!() - } - - // /// Return the path stored in .gitmodules - // pub fn simple_path(&self) -> Option<&str> { - // self.in_gitmodules?.path.as_ref().map(|s| s.as_str()) - // } - /// Get the URL of the submodule with the best effort. /// /// Follows the order: @@ -76,9 +66,7 @@ impl Submodule { /// 2. URL in .gitmodules pub fn url(&self) -> Option<&str> { if let Some(config) = &self.in_config { - if let Some(url) = &config.url { - return Some(url.as_str()); - } + return Some(config.url.as_str()); } if let Some(gitmodules) = &self.in_gitmodules { if let Some(url) = &gitmodules.url { @@ -185,7 +173,7 @@ impl Submodule { } } println_warn!("! checked out {head_commit_short}{describe}"); - println_dimmed!( + println_hint!( " run `magoo{dir_switch} install` to revert all submodules to index" ); if let Some(path) = path { @@ -195,8 +183,8 @@ impl Submodule { None => "git".to_string(), }; - println_dimmed!(" run `{git_c} submodule update --init -- {path}` to revert this submodule to index"); - println_dimmed!(" run `{git_c} add {path}` update the index to {head_commit_short}{describe}"); + println_hint!(" run `{git_c} submodule update --init -- {path}` to revert this submodule to index"); + println_hint!(" run `{git_c} add {path}` update the index to {head_commit_short}{describe}"); } } } @@ -210,27 +198,27 @@ impl Submodule { None => "git".to_string(), }; - println_dimmed!(" run `magoo{dir_switch} install` initialize all submodules"); - println_dimmed!(" run `{git_c} submodule update --init -- {path}` to initialize only this submodule"); + println_hint!(" run `magoo{dir_switch} install` to initialize all submodules"); + println_hint!(" run `{git_c} submodule update --init -- {path}` to initialize only this submodule"); } } if !self.is_module_consistent(context)? { println_error!("! submodule has residue"); - println_dimmed!( + println_hint!( " run `magoo{dir_switch} status --fix{all_switch}` to fix all submodules" ); } if !self.resolved_paths(context)?.is_consistent() { println_error!("! inconsistent paths"); - println_dimmed!( + println_hint!( " run `magoo{dir_switch} status --fix{all_switch}` to fix all submodules" ); } let issue = self.find_issue(); if issue != PartsIssue::None { println_error!("! inconsistent state ({})", issue.describe()); - println_dimmed!( + println_hint!( " run `magoo{dir_switch} status --fix{all_switch}` to fix all submodules" ); } @@ -240,7 +228,7 @@ impl Submodule { } /// Return false if the submodule has issues that can be fixed with [`fix`] - pub fn is_healty(&self, context: &GitContext) -> Result { + pub fn is_healthy(&self, context: &GitContext) -> Result { if !self.is_module_consistent(context)? { return Ok(false); } @@ -420,7 +408,7 @@ impl Submodule { } /// Deinitialize the submodule by removing the worktree and the git dir, and in .git/config - pub fn deinit(&mut self, context: &GitContext) -> Result<(), GitError> { + pub fn deinit(&mut self, context: &GitContext, force: bool) -> Result<(), GitError> { let path = match &self.in_index { None => { return Err(GitError::InvalidIndex( @@ -429,7 +417,7 @@ impl Submodule { } Some(index) => &index.path, }; - context.submodule_deinit(Some(path))?; + context.submodule_deinit(Some(path), force)?; self.force_remove_module_dir(context) } @@ -541,16 +529,7 @@ pub struct InGitConfig { /// Name of the submodule, stored in the section name pub name: String, /// URL of the submodule, stored as `submodule..url` - pub url: Option, -} - -impl InGitConfig { - pub fn with_name(name: &str) -> Self { - Self { - name: name.to_string(), - ..Default::default() - } - } + pub url: String, } /// Data of submodule stored in .git/modules @@ -623,7 +602,7 @@ impl PartsIssue { pub fn describe(&self) -> &'static str { match self { PartsIssue::None => "none", - PartsIssue::Residue => "eesidue", + PartsIssue::Residue => "residue", PartsIssue::MissingIndex => "index missing", PartsIssue::MissingInGitModules => "not in .gitmodules", }