diff --git a/README.org b/README.org index eec10c8..1a06d27 100644 --- a/README.org +++ b/README.org @@ -16,6 +16,13 @@ To get the recent changes comparing to the default branch you can type: $ git-changed #+end_src +In a subdirectory, the tool prints the paths relative to that directory, like ~git status~. The glob also matches the relative path: + +#+begin_src bash +$ cd src +$ git-changed '*.rs' | xargs rustfmt +#+end_src + *** Default branch Per default the script uses the ~init.defaultBranch~ if that is configured, and falls back to ~main~, you can override this setting by a command line parameter: @@ -30,6 +37,12 @@ Or you can set the configuration globally: $ git config --global changed.defaultBranch development #+end_src +Or only for the current repository: + +#+begin_src bash +$ git config changed.defaultBranch development +#+end_src + *** Filtering files with glob pattern #+begin_src bash diff --git a/src/cli.rs b/src/cli.rs index ce8515b..f70aa49 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,19 +1,14 @@ -use anyhow::Result; use clap::builder::Arg; use clap::builder::ArgAction; use clap::builder::Command; use clap::ArgMatches; -use git2::Config; use glob::Pattern; use std::fmt; -const CONFIG_PATHS: [&str; 2] = ["changed.defaultBranch", "init.defaultBranch"]; -const DEFAULT_BRANCH: &str = "main"; - #[derive(Debug, Clone)] pub struct Options { pub pattern: Pattern, - pub base: String, + pub base: Option, pub null: bool, pub exist: bool, } @@ -22,25 +17,12 @@ impl fmt::Display for Options { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, - "pattern: {}, base: {}, null: {}, exist: {}", + "pattern: {}, base: {:?}, null: {}, exist: {}", self.pattern, self.base, self.null, self.exist ) } } -fn default_branch() -> String { - let config = Config::open_default().expect("Failed to open global config"); - - let branch = CONFIG_PATHS - .into_iter() - .find_map(|path| match config.get_entry(path) { - Ok(entry) => entry.value().map(str::to_string), - Err(_e) => None, - }); - - branch.unwrap_or(DEFAULT_BRANCH.to_owned()) -} - fn command() -> Command { Command::new("git-changed") .about("Lists files modified since branching off") @@ -73,10 +55,7 @@ fn matches_to_options(matches: ArgMatches) -> Options { let pattern = Pattern::new(glob).expect("Valid glob required"); - let base = match matches.get_one::("base") { - Some(value) => value.to_owned(), - None => default_branch(), - }; + let base = matches.get_one::("base").cloned(); let null = match matches.get_one::("null") { Some(value) => value.to_owned(), @@ -96,8 +75,6 @@ fn matches_to_options(matches: ArgMatches) -> Options { } } -pub fn get_options() -> Result { - let matches = command().try_get_matches()?; - - Ok(matches_to_options(matches)) +pub fn get_options() -> Options { + matches_to_options(command().get_matches()) } diff --git a/src/git.rs b/src/git.rs index 4c979d1..12ec63a 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,96 +1,143 @@ use anyhow::Context; use anyhow::Result; -use git2::BranchType; use git2::DiffDelta; +use git2::ErrorCode; use git2::Oid; use git2::Repository; use git2::StatusOptions; use git2::Tree; -use std::collections::HashSet; -use std::env; +use std::collections::BTreeSet; +use std::path::Path; +use std::path::PathBuf; -fn status_options() -> StatusOptions { - let mut options = StatusOptions::default(); - - options.include_untracked(true); - options.exclude_submodules(true); +const CONFIG_PATHS: [&str; 2] = ["changed.defaultBranch", "init.defaultBranch"]; +const DEFAULT_BRANCH: &str = "main"; - options +pub fn open_repository(directory: &Path) -> Result { + Repository::discover(directory) + .with_context(|| format!("Cannot find a repository at {}", directory.display())) } -fn uncommitted_changes(repo: &Repository) -> Result> { - let mut options = status_options(); +pub fn subdirectory(repo: &Repository, directory: &Path) -> Result { + let root = repo + .workdir() + .context("The repository has no working directory")?; - let statuses = repo - .statuses(Some(&mut options)) - .context("Could not get statuses")?; + let root = canonical(root)?; + let directory = canonical(directory)?; - statuses - .iter() - .map(|entry| Ok(entry.path().context("Could not get path")?.to_string())) - .collect() + let subdirectory = directory + .strip_prefix(&root) + .with_context(|| format!("{} is not in {}", directory.display(), root.display()))?; + + Ok(subdirectory.to_path_buf()) } -fn find_tree(repo: &Repository, oid: Oid) -> Result> { - repo.find_commit(oid)? - .tree() - .with_context(|| format!("Failed to find tree for {}", oid)) +pub fn changes(repo: &Repository, base: Option<&str>) -> Result> { + let base = match base { + Some(base) => base.to_string(), + None => default_branch(repo)?, + }; + + let committed = committed_changes(repo, &base)?.into_iter(); + let uncommitted = uncommitted_changes(repo)?.into_iter(); + + let files = committed.chain(uncommitted); + + Ok(BTreeSet::from_iter(files)) } -fn find_base_oid(repo: &Repository, base: String) -> Result { - let oid = Oid::from_str(base.as_str()); +fn canonical(path: &Path) -> Result { + path.canonicalize() + .with_context(|| format!("Cannot resolve the path {}", path.display())) +} - if let Ok(value) = oid { - return Ok(value); +fn default_branch(repo: &Repository) -> Result { + let config = repo.config().context("Cannot read the git config")?; + + for path in CONFIG_PATHS { + match config.get_string(path) { + Ok(branch) => return Ok(branch), + Err(error) if error.code() == ErrorCode::NotFound => continue, + Err(error) => { + return Err(error) + .with_context(|| format!("Cannot read {path} from the git config")) + } + } } - repo.find_branch(&base, BranchType::Local)? - .get() - .target() - .with_context(|| format!("Failed to fetch branch {}", base)) + Ok(DEFAULT_BRANCH.to_string()) } -fn committed_changes(repo: &Repository, base: &String) -> Result> { - let base_oid = find_base_oid(repo, base.to_string())?; +fn committed_changes(repo: &Repository, base: &str) -> Result> { + let base_oid = find_commit_oid(repo, base)?; + let head_oid = find_commit_oid(repo, "HEAD")?; - let head_oid = repo.head()?.target().context("Could not get oid")?; - - let base = repo + let merge_base_oid = repo .merge_base(base_oid, head_oid) - .with_context(|| format!("Failed to find merge base for {} and HEAD", base))?; + .with_context(|| format!("Cannot find a merge base of {base} and HEAD"))?; - let old_tree = find_tree(repo, base)?; + let old_tree = find_tree(repo, merge_base_oid)?; let new_tree = find_tree(repo, head_oid)?; - let diffs = repo + let diff = repo .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None) - .with_context(|| "Could not get the diffs")?; + .with_context(|| format!("Cannot compare {base} with HEAD"))?; - diffs.deltas().map(&map_new_files).collect() + diff.deltas().map(changed_path).collect() } -fn map_new_files(delta: DiffDelta) -> Result { +fn find_commit_oid(repo: &Repository, revision: &str) -> Result { + let commit = repo + .revparse_single(revision) + .and_then(|object| object.peel_to_commit()) + .with_context(|| format!("Cannot find the commit of {revision}"))?; + + Ok(commit.id()) +} + +fn find_tree(repo: &Repository, oid: Oid) -> Result> { + repo.find_commit(oid) + .and_then(|commit| commit.tree()) + .with_context(|| format!("Cannot find the tree of {oid}")) +} + +fn changed_path(delta: DiffDelta) -> Result { let path = delta .new_file() .path() - .context("Failed to get file")? + .context("A changed file has no path")?; + + let text = path .to_str() - .context("Failed to get path")? - .to_string(); + .with_context(|| format!("The path {} is not UTF-8", path.display()))?; - Ok(path) + Ok(text.to_string()) } -pub fn changes(base: &String) -> Result> { - let cwd = env::current_dir()?; +fn uncommitted_changes(repo: &Repository) -> Result> { + let statuses = repo + .statuses(Some(&mut status_options())) + .context("Cannot read the status of the working directory")?; - let repo = Repository::open(cwd.clone()) - .with_context(|| format!("Could not open repository at {}", cwd.display()))?; + statuses + .iter() + .map(|entry| { + let path = entry + .path() + .context("A changed file has a path that is not UTF-8")?; - let committed = committed_changes(&repo, base)?.into_iter(); - let uncommitted = uncommitted_changes(&repo)?.into_iter(); + Ok(path.to_string()) + }) + .collect() +} - let files = committed.chain(uncommitted); +fn status_options() -> StatusOptions { + let mut options = StatusOptions::default(); - Ok(HashSet::from_iter(files)) + options.include_untracked(true); + options.recurse_untracked_dirs(true); + options.exclude_submodules(true); + + options } diff --git a/src/main.rs b/src/main.rs index 7f7089a..5d5fec3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,45 +1,85 @@ use self::cli::Options; +use anyhow::Context; use anyhow::Result; -use std::{path::Path, process}; +use std::env; +use std::io; +use std::io::Write; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use std::process; mod cli; mod git; -fn matches(file: &String, options: &Options) -> bool { - if !options.pattern.matches(file) { - return false; - } +fn main() { + let options = cli::get_options(); - if !options.exist { - return true; + if let Err(error) = execute(options) { + if is_broken_pipe(&error) { + return; + } + + eprintln!("{error:#}"); + process::exit(1); } +} - Path::new(file).exists() +fn is_broken_pipe(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(|error| error.kind() == io::ErrorKind::BrokenPipe) } fn execute(options: Options) -> Result<()> { - let changes = git::changes(&options.base)?; - let separator = if options.null { '\0' } else { '\n' }; + let directory = env::current_dir().context("Cannot read the current directory")?; + let repo = git::open_repository(&directory)?; + let subdirectory = git::subdirectory(&repo, &directory)?; - for file in &changes { - if matches(file, &options) { - print!("{}{}", file, separator); - } - } + let changes = git::changes(&repo, options.base.as_deref())?; + let selected = changes + .iter() + .map(|file| relative_path(Path::new(file), &subdirectory)) + .filter(|file| is_selected(file, &options)); + + print_files(selected, options.null)?; Ok(()) } -fn main() -> Result<()> { - let options = cli::get_options()?; +fn relative_path(file: &Path, directory: &Path) -> PathBuf { + let mut file_parts = file.components().peekable(); + let mut directory_parts = directory.components().peekable(); - match execute(options) { - Ok(()) => { - process::exit(0); - } - Err(error) => { - eprintln!("{}", error); - process::exit(1); - } + while file_parts.peek().is_some() && file_parts.peek() == directory_parts.peek() { + file_parts.next(); + directory_parts.next(); + } + + let parents = directory_parts.map(|_| Component::ParentDir); + + parents.chain(file_parts).collect() +} + +fn is_selected(file: &Path, options: &Options) -> bool { + if !options.pattern.matches_path(file) { + return false; + } + + if !options.exist { + return true; } + + file.exists() +} + +fn print_files(files: impl Iterator, null: bool) -> io::Result<()> { + let separator = if null { '\0' } else { '\n' }; + let mut stdout = io::stdout().lock(); + + for file in files { + write!(stdout, "{}{separator}", file.display())?; + } + + stdout.flush() } diff --git a/tests/features.rs b/tests/features.rs index 6999701..f0e9ae1 100644 --- a/tests/features.rs +++ b/tests/features.rs @@ -7,6 +7,7 @@ use std::fs; use std::path::Path; use std::process::Command; use std::process::Output; +use std::process::Stdio; use tempfile::TempDir; fn main() { @@ -41,14 +42,34 @@ fn run_command(world: &mut GitWorld, command: String) { #[when(expr = "the user executes the tool like: {string}")] fn run_tool(world: &mut GitWorld, command: String) { - let (program, args) = split_command(&command); - assert_eq!(program, "git-changed", "This step runs only git-changed"); + run_tool_in(world, ".".to_string(), command); +} + +#[when(expr = "the user executes the tool in {string} like: {string}")] +fn run_tool_in(world: &mut GitWorld, subdirectory: String, command: String) { + let args = tool_args(&command); - let output = world.run(env!("CARGO_BIN_EXE_git-changed"), &args); + let output = world.run_in(&subdirectory, env!("CARGO_BIN_EXE_git-changed"), &args); world.output = Some(output); } +#[when(expr = "the user executes the tool with a closed output like: {string}")] +fn run_tool_with_closed_output(world: &mut GitWorld, command: String) { + let args = tool_args(&command); + + let mut child = world + .command_in(".", env!("CARGO_BIN_EXE_git-changed")) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Cannot start the tool"); + drop(child.stdout.take()); + + world.output = Some(child.wait_with_output().expect("Cannot wait for the tool")); +} + #[then("it fails with the following error")] fn assert_failure(world: &mut GitWorld, step: &Step) { let output = world.tool_output(); @@ -61,12 +82,28 @@ fn assert_failure(world: &mut GitWorld, step: &Step) { fn assert_success(world: &mut GitWorld, step: &Step) { let output = world.tool_output(); + assert_succeeded(output); + assert_eq!(text(&output.stdout), expected_text(step)); +} + +#[then("it succeeds with the following NUL-separated output")] +fn assert_success_with_nul(world: &mut GitWorld, step: &Step) { + let output = world.tool_output(); + let expected: String = expected_text(step) + .lines() + .map(|line| format!("{line}\0")) + .collect(); + + assert_succeeded(output); + assert_eq!(String::from_utf8_lossy(&output.stdout), expected); +} + +fn assert_succeeded(output: &Output) { assert!( output.status.success(), "The tool failed: {}", text(&output.stderr) ); - assert_eq!(text(&output.stdout), expected_text(step)); } impl GitWorld { @@ -82,12 +119,22 @@ impl GitWorld { } fn run(&self, program: &str, args: &[String]) -> Output { - command_without_user_config(program, self.home.path(), self.directory.path()) + self.run_in(".", program, args) + } + + fn run_in(&self, subdirectory: &str, program: &str, args: &[String]) -> Output { + self.command_in(subdirectory, program) .args(args) .output() .unwrap_or_else(|error| panic!("Cannot run {program}: {error}")) } + fn command_in(&self, subdirectory: &str, program: &str) -> Command { + let directory = self.directory.path().join(subdirectory); + + command_without_user_config(program, self.home.path(), &directory) + } + fn tool_output(&self) -> &Output { self.output.as_ref().expect("The tool did not run") } @@ -113,6 +160,13 @@ fn write_global_config(home: &Path) { fs::write(home.join(".gitconfig"), config).expect("Cannot write the global git config"); } +fn tool_args(command: &str) -> Vec { + let (program, args) = split_command(command); + assert_eq!(program, "git-changed", "This step runs only git-changed"); + + args +} + fn split_command(command: &str) -> (String, Vec) { let mut parts = shlex::split(command).unwrap_or_else(|| panic!("Cannot parse the command: {command}")); diff --git a/tests/features/base.feature b/tests/features/base.feature new file mode 100644 index 0000000..8ae59b3 --- /dev/null +++ b/tests/features/base.feature @@ -0,0 +1,35 @@ +Feature: Compare with a given base + + Background: A feature branch with a committed file + Given an empty directory + When the user executes the command: "git init --initial-branch=main" + And the user executes the command: "git config user.name 'John Doe'" + And the user executes the command: "git config user.email 'jdoe@example.com'" + And the user executes the command: "git commit --allow-empty --message 'Initial commit'" + And the user executes the command: "git switch --create feature" + And the user executes the command: "touch README.md" + And the user executes the command: "git add README.md" + And the user executes the command: "git commit --message 'Adding README'" + + Scenario: It uses a branch with a name that looks like a commit ID + When the user executes the command: "git branch cafe main" + And the user executes the tool like: "git-changed --base cafe" + Then it succeeds with the following output + """ + README.md + """ + + Scenario: It uses a tag + When the user executes the command: "git tag start main" + And the user executes the tool like: "git-changed --base start" + Then it succeeds with the following output + """ + README.md + """ + + Scenario: It uses a relative revision + When the user executes the tool like: "git-changed --base HEAD~1" + Then it succeeds with the following output + """ + README.md + """ diff --git a/tests/features/config.feature b/tests/features/config.feature new file mode 100644 index 0000000..5f1c370 --- /dev/null +++ b/tests/features/config.feature @@ -0,0 +1,19 @@ +Feature: Default branch from the git config + + Background: A feature branch with a committed file + Given an empty directory + When the user executes the command: "git init --initial-branch=main" + And the user executes the command: "git config user.name 'John Doe'" + And the user executes the command: "git config user.email 'jdoe@example.com'" + And the user executes the command: "git commit --allow-empty --message 'Initial commit'" + And the user executes the command: "git switch --create feature" + And the user executes the command: "touch README.md" + And the user executes the command: "git add README.md" + And the user executes the command: "git commit --message 'Adding README'" + + Scenario: It uses the default branch from the repository config + When the user executes the command: "git config changed.defaultBranch feature" + And the user executes the tool like: "git-changed" + Then it succeeds with the following output + """ + """ diff --git a/tests/features/help.feature b/tests/features/help.feature index 56e4fa3..bf5ed02 100644 --- a/tests/features/help.feature +++ b/tests/features/help.feature @@ -3,9 +3,9 @@ Feature: Help Scenario: If the user asks for help Given an empty directory When the user executes the tool like: "git-changed -h" - Then it fails with the following error + Then it succeeds with the following output """ - Error: Lists files modified since branching off + Lists files modified since branching off Usage: git-changed [OPTIONS] [glob] diff --git a/tests/features/output.feature b/tests/features/output.feature new file mode 100644 index 0000000..d7fef62 --- /dev/null +++ b/tests/features/output.feature @@ -0,0 +1,37 @@ +Feature: Output + + Background: A git repository with untracked files + Given an empty directory + When the user executes the command: "git init --initial-branch=main" + And the user executes the command: "git config user.name 'John Doe'" + And the user executes the command: "git config user.email 'jdoe@example.com'" + And the user executes the command: "git commit --allow-empty --message 'Initial commit'" + And the user executes the command: "touch c.md a.md e.md b.md d.md" + + Scenario: It displays the files in sorted order + When the user executes the tool like: "git-changed" + Then it succeeds with the following output + """ + a.md + b.md + c.md + d.md + e.md + """ + + Scenario: It stops without an error if the reader closes the output + When the user executes the tool with a closed output like: "git-changed" + Then it succeeds with the following output + """ + """ + + Scenario: It separates the files with NUL characters + When the user executes the tool like: "git-changed -0" + Then it succeeds with the following NUL-separated output + """ + a.md + b.md + c.md + d.md + e.md + """ diff --git a/tests/features/subdirectory.feature b/tests/features/subdirectory.feature new file mode 100644 index 0000000..e4d74e7 --- /dev/null +++ b/tests/features/subdirectory.feature @@ -0,0 +1,42 @@ +Feature: Run the tool in a subdirectory + + Background: A git repository with a new file in a subdirectory + Given an empty directory + When the user executes the command: "git init --initial-branch=main" + And the user executes the command: "git config user.name 'John Doe'" + And the user executes the command: "git config user.email 'jdoe@example.com'" + And the user executes the command: "git commit --allow-empty --message 'Initial commit'" + And the user executes the command: "mkdir src" + And the user executes the command: "touch README.md src/main.rs" + And the user executes the command: "git add README.md src/main.rs" + + Scenario: It displays all changes relative to the subdirectory + When the user executes the tool in "src" like: "git-changed" + Then it succeeds with the following output + """ + ../README.md + main.rs + """ + + Scenario: It matches the glob against the relative path + When the user executes the tool in "src" like: "git-changed *.rs" + Then it succeeds with the following output + """ + main.rs + """ + + Scenario: It finds the existing files from the subdirectory + When the user executes the tool in "src" like: "git-changed -e ../*.md" + Then it succeeds with the following output + """ + ../README.md + """ + + Scenario: It goes up to the shared directory from a deeper subdirectory + When the user executes the command: "mkdir src/lib" + And the user executes the tool in "src/lib" like: "git-changed" + Then it succeeds with the following output + """ + ../../README.md + ../main.rs + """ diff --git a/tests/features/untracked.feature b/tests/features/untracked.feature new file mode 100644 index 0000000..ace4476 --- /dev/null +++ b/tests/features/untracked.feature @@ -0,0 +1,17 @@ +Feature: Untracked files + + Background: A git repository with an untracked directory + Given an empty directory + When the user executes the command: "git init --initial-branch=main" + And the user executes the command: "git config user.name 'John Doe'" + And the user executes the command: "git config user.email 'jdoe@example.com'" + And the user executes the command: "git commit --allow-empty --message 'Initial commit'" + And the user executes the command: "mkdir docs" + And the user executes the command: "touch docs/guide.md" + + Scenario: It displays the files in the untracked directory + When the user executes the tool like: "git-changed" + Then it succeeds with the following output + """ + docs/guide.md + """