From 5c68d2f11514a99f66de1611719ad3af6ad46e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KARASZI=20Istv=C3=A1n?= Date: Fri, 13 Nov 2026 18:39:27 +0100 Subject: [PATCH 01/12] Exit with 0 for --help --- src/cli.rs | 7 ++----- src/main.rs | 15 +++++---------- tests/features/help.feature | 4 ++-- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index ce8515b..5ecec03 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,3 @@ -use anyhow::Result; use clap::builder::Arg; use clap::builder::ArgAction; use clap::builder::Command; @@ -96,8 +95,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/main.rs b/src/main.rs index 7f7089a..c05a673 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,16 +30,11 @@ fn execute(options: Options) -> Result<()> { Ok(()) } -fn main() -> Result<()> { - let options = cli::get_options()?; +fn main() { + let options = cli::get_options(); - match execute(options) { - Ok(()) => { - process::exit(0); - } - Err(error) => { - eprintln!("{}", error); - process::exit(1); - } + if let Err(error) = execute(options) { + eprintln!("{error}"); + process::exit(1); } } 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] From ba695bb6ac7e20134a5302aeabb4356f09a1dc10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KARASZI=20Istv=C3=A1n?= Date: Fri, 13 Nov 2026 18:39:59 +0100 Subject: [PATCH 02/12] Find the repository from a subdirectory The -e option now checks each file from the root of the repository. --- src/git.rs | 12 +++++++----- src/main.rs | 13 +++++++++---- tests/features.rs | 15 +++++++++++++-- tests/features/subdirectory.feature | 25 +++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 11 deletions(-) create mode 100644 tests/features/subdirectory.feature diff --git a/src/git.rs b/src/git.rs index 4c979d1..7c0d7d3 100644 --- a/src/git.rs +++ b/src/git.rs @@ -81,14 +81,16 @@ fn map_new_files(delta: DiffDelta) -> Result { Ok(path) } -pub fn changes(base: &String) -> Result> { +pub fn open_repository() -> Result { let cwd = env::current_dir()?; - let repo = Repository::open(cwd.clone()) - .with_context(|| format!("Could not open repository at {}", cwd.display()))?; + Repository::discover(&cwd) + .with_context(|| format!("Cannot find a repository at {}", cwd.display())) +} - let committed = committed_changes(&repo, base)?.into_iter(); - let uncommitted = uncommitted_changes(&repo)?.into_iter(); +pub fn changes(repo: &Repository, base: &String) -> Result> { + let committed = committed_changes(repo, base)?.into_iter(); + let uncommitted = uncommitted_changes(repo)?.into_iter(); let files = committed.chain(uncommitted); diff --git a/src/main.rs b/src/main.rs index c05a673..68de4d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,12 @@ use self::cli::Options; +use anyhow::Context; use anyhow::Result; use std::{path::Path, process}; mod cli; mod git; -fn matches(file: &String, options: &Options) -> bool { +fn matches(file: &str, root: &Path, options: &Options) -> bool { if !options.pattern.matches(file) { return false; } @@ -14,15 +15,19 @@ fn matches(file: &String, options: &Options) -> bool { return true; } - Path::new(file).exists() + root.join(file).exists() } fn execute(options: Options) -> Result<()> { - let changes = git::changes(&options.base)?; + let repo = git::open_repository()?; + let root = repo + .workdir() + .context("The repository has no working directory")?; + let changes = git::changes(&repo, &options.base)?; let separator = if options.null { '\0' } else { '\n' }; for file in &changes { - if matches(file, &options) { + if matches(file, root, &options) { print!("{}{}", file, separator); } } diff --git a/tests/features.rs b/tests/features.rs index 6999701..a4d2fc5 100644 --- a/tests/features.rs +++ b/tests/features.rs @@ -41,10 +41,15 @@ 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) { + 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 (program, args) = split_command(&command); assert_eq!(program, "git-changed", "This step runs only git-changed"); - 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); } @@ -82,7 +87,13 @@ 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 { + let directory = self.directory.path().join(subdirectory); + + command_without_user_config(program, self.home.path(), &directory) .args(args) .output() .unwrap_or_else(|error| panic!("Cannot run {program}: {error}")) diff --git a/tests/features/subdirectory.feature b/tests/features/subdirectory.feature new file mode 100644 index 0000000..782a50e --- /dev/null +++ b/tests/features/subdirectory.feature @@ -0,0 +1,25 @@ +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 the changes of the repository + When the user executes the tool in "src" like: "git-changed *.rs" + Then it succeeds with the following output + """ + src/main.rs + """ + + Scenario: It finds the existing files from the root of the repository + When the user executes the tool in "src" like: "git-changed -e *.md" + Then it succeeds with the following output + """ + README.md + """ From a6d861768cbb0fac6a97eb1992233074b95f905b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KARASZI=20Istv=C3=A1n?= Date: Fri, 13 Nov 2026 18:40:22 +0100 Subject: [PATCH 03/12] Resolve the base with revparse A branch with a hex name, a tag, or a relative revision now works as the base. --- src/git.rs | 19 +++++++------------ tests/features/base.feature | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 12 deletions(-) create mode 100644 tests/features/base.feature diff --git a/src/git.rs b/src/git.rs index 7c0d7d3..e782f1f 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,6 +1,5 @@ use anyhow::Context; use anyhow::Result; -use git2::BranchType; use git2::DiffDelta; use git2::Oid; use git2::Repository; @@ -37,21 +36,17 @@ fn find_tree(repo: &Repository, oid: Oid) -> Result> { .with_context(|| format!("Failed to find tree for {}", oid)) } -fn find_base_oid(repo: &Repository, base: String) -> Result { - let oid = Oid::from_str(base.as_str()); +fn find_base_oid(repo: &Repository, base: &str) -> Result { + let commit = repo + .revparse_single(base) + .and_then(|object| object.peel_to_commit()) + .with_context(|| format!("Cannot find the commit of {base}"))?; - if let Ok(value) = oid { - return Ok(value); - } - - repo.find_branch(&base, BranchType::Local)? - .get() - .target() - .with_context(|| format!("Failed to fetch branch {}", base)) + Ok(commit.id()) } fn committed_changes(repo: &Repository, base: &String) -> Result> { - let base_oid = find_base_oid(repo, base.to_string())?; + let base_oid = find_base_oid(repo, base)?; let head_oid = repo.head()?.target().context("Could not get oid")?; 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 + """ From 0f1d64702f38f0caf1abb7d5c33e280e830120fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KARASZI=20Istv=C3=A1n?= Date: Fri, 13 Nov 2026 18:40:35 +0100 Subject: [PATCH 04/12] List the files in untracked directories --- src/git.rs | 1 + tests/features/untracked.feature | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 tests/features/untracked.feature diff --git a/src/git.rs b/src/git.rs index e782f1f..34dcbbb 100644 --- a/src/git.rs +++ b/src/git.rs @@ -12,6 +12,7 @@ fn status_options() -> StatusOptions { let mut options = StatusOptions::default(); options.include_untracked(true); + options.recurse_untracked_dirs(true); options.exclude_submodules(true); options 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 + """ From 2b9b5807f647db0120917d5290e534045af16051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KARASZI=20Istv=C3=A1n?= Date: Fri, 13 Nov 2026 18:40:52 +0100 Subject: [PATCH 05/12] Sort the output --- src/git.rs | 6 +++--- tests/features/output.feature | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 tests/features/output.feature diff --git a/src/git.rs b/src/git.rs index 34dcbbb..4264b2e 100644 --- a/src/git.rs +++ b/src/git.rs @@ -5,7 +5,7 @@ use git2::Oid; use git2::Repository; use git2::StatusOptions; use git2::Tree; -use std::collections::HashSet; +use std::collections::BTreeSet; use std::env; fn status_options() -> StatusOptions { @@ -84,11 +84,11 @@ pub fn open_repository() -> Result { .with_context(|| format!("Cannot find a repository at {}", cwd.display())) } -pub fn changes(repo: &Repository, base: &String) -> Result> { +pub fn changes(repo: &Repository, base: &String) -> Result> { let committed = committed_changes(repo, base)?.into_iter(); let uncommitted = uncommitted_changes(repo)?.into_iter(); let files = committed.chain(uncommitted); - Ok(HashSet::from_iter(files)) + Ok(BTreeSet::from_iter(files)) } diff --git a/tests/features/output.feature b/tests/features/output.feature new file mode 100644 index 0000000..bfda85c --- /dev/null +++ b/tests/features/output.feature @@ -0,0 +1,20 @@ +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 + """ From e4723b598f3520611244ace52dcba1ea5c1b8761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KARASZI=20Istv=C3=A1n?= Date: Fri, 13 Nov 2026 18:41:30 +0100 Subject: [PATCH 06/12] Stop without an error on a closed output --- src/main.rs | 24 +++++++++++++++++++----- tests/features.rs | 28 +++++++++++++++++++++++++--- tests/features/output.feature | 6 ++++++ 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index 68de4d2..6cff2b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,10 @@ use self::cli::Options; use anyhow::Context; use anyhow::Result; -use std::{path::Path, process}; +use std::io; +use std::io::Write; +use std::path::Path; +use std::process; mod cli; mod git; @@ -26,19 +29,30 @@ fn execute(options: Options) -> Result<()> { let changes = git::changes(&repo, &options.base)?; let separator = if options.null { '\0' } else { '\n' }; - for file in &changes { - if matches(file, root, &options) { - print!("{}{}", file, separator); - } + let mut stdout = io::stdout().lock(); + for file in changes.iter().filter(|file| matches(file, root, &options)) { + write!(stdout, "{file}{separator}")?; } + stdout.flush()?; + Ok(()) } +fn is_broken_pipe(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(|error| error.kind() == io::ErrorKind::BrokenPipe) +} + fn main() { let options = cli::get_options(); if let Err(error) = execute(options) { + if is_broken_pipe(&error) { + return; + } + eprintln!("{error}"); process::exit(1); } diff --git a/tests/features.rs b/tests/features.rs index a4d2fc5..d1bcc62 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() { @@ -54,6 +55,23 @@ fn run_tool_in(world: &mut GitWorld, subdirectory: String, command: String) { 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 (program, args) = split_command(&command); + assert_eq!(program, "git-changed", "This step runs only git-changed"); + + 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(); @@ -91,14 +109,18 @@ impl GitWorld { } fn run_in(&self, subdirectory: &str, program: &str, args: &[String]) -> Output { - let directory = self.directory.path().join(subdirectory); - - command_without_user_config(program, self.home.path(), &directory) + 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") } diff --git a/tests/features/output.feature b/tests/features/output.feature index bfda85c..4e0ee16 100644 --- a/tests/features/output.feature +++ b/tests/features/output.feature @@ -18,3 +18,9 @@ Feature: Output 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 + """ + """ From d0875d1e02fd13446d6b32e175f32adeb721dfc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KARASZI=20Istv=C3=A1n?= Date: Fri, 13 Nov 2026 18:42:04 +0100 Subject: [PATCH 07/12] Read the default branch from the repository config A missing config now gives an error instead of a panic. --- README.org | 6 ++++++ src/cli.rs | 26 +++----------------------- src/git.rs | 24 +++++++++++++++++++++--- src/main.rs | 2 +- tests/features/config.feature | 19 +++++++++++++++++++ 5 files changed, 50 insertions(+), 27 deletions(-) create mode 100644 tests/features/config.feature diff --git a/README.org b/README.org index eec10c8..a0c3fc6 100644 --- a/README.org +++ b/README.org @@ -30,6 +30,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 5ecec03..f70aa49 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,17 +2,13 @@ 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, } @@ -21,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") @@ -72,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(), diff --git a/src/git.rs b/src/git.rs index 4264b2e..eba8340 100644 --- a/src/git.rs +++ b/src/git.rs @@ -8,6 +8,9 @@ use git2::Tree; use std::collections::BTreeSet; use std::env; +const CONFIG_PATHS: [&str; 2] = ["changed.defaultBranch", "init.defaultBranch"]; +const DEFAULT_BRANCH: &str = "main"; + fn status_options() -> StatusOptions { let mut options = StatusOptions::default(); @@ -46,7 +49,7 @@ fn find_base_oid(repo: &Repository, base: &str) -> Result { Ok(commit.id()) } -fn committed_changes(repo: &Repository, base: &String) -> Result> { +fn committed_changes(repo: &Repository, base: &str) -> Result> { let base_oid = find_base_oid(repo, base)?; let head_oid = repo.head()?.target().context("Could not get oid")?; @@ -77,6 +80,16 @@ fn map_new_files(delta: DiffDelta) -> Result { Ok(path) } +fn default_branch(repo: &Repository) -> Result { + let config = repo.config().context("Cannot read the git config")?; + + let branch = CONFIG_PATHS + .into_iter() + .find_map(|path| config.get_string(path).ok()); + + Ok(branch.unwrap_or_else(|| DEFAULT_BRANCH.to_string())) +} + pub fn open_repository() -> Result { let cwd = env::current_dir()?; @@ -84,8 +97,13 @@ pub fn open_repository() -> Result { .with_context(|| format!("Cannot find a repository at {}", cwd.display())) } -pub fn changes(repo: &Repository, base: &String) -> Result> { - let committed = committed_changes(repo, base)?.into_iter(); +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); diff --git a/src/main.rs b/src/main.rs index 6cff2b6..646a8fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,7 @@ fn execute(options: Options) -> Result<()> { let root = repo .workdir() .context("The repository has no working directory")?; - let changes = git::changes(&repo, &options.base)?; + let changes = git::changes(&repo, options.base.as_deref())?; let separator = if options.null { '\0' } else { '\n' }; let mut stdout = io::stdout().lock(); 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 + """ + """ From 7cc33c3cb25436393a6e682fcd066ae357d67b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KARASZI=20Istv=C3=A1n?= Date: Fri, 13 Nov 2026 18:44:12 +0100 Subject: [PATCH 08/12] Clean up the names and the error messages A config value that git2 cannot read now gives an error instead of the fallback. --- src/git.rs | 140 +++++++++++++++++++++++++--------------------- src/main.rs | 63 ++++++++++++--------- tests/features.rs | 13 +++-- 3 files changed, 121 insertions(+), 95 deletions(-) diff --git a/src/git.rs b/src/git.rs index eba8340..e3839d2 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,6 +1,7 @@ use anyhow::Context; use anyhow::Result; use git2::DiffDelta; +use git2::ErrorCode; use git2::Oid; use git2::Repository; use git2::StatusOptions; @@ -11,102 +12,113 @@ use std::env; const CONFIG_PATHS: [&str; 2] = ["changed.defaultBranch", "init.defaultBranch"]; const DEFAULT_BRANCH: &str = "main"; -fn status_options() -> StatusOptions { - let mut options = StatusOptions::default(); - - options.include_untracked(true); - options.recurse_untracked_dirs(true); - options.exclude_submodules(true); +pub fn open_repository() -> Result { + let cwd = env::current_dir()?; - options + Repository::discover(&cwd) + .with_context(|| format!("Cannot find a repository at {}", cwd.display())) } -fn uncommitted_changes(repo: &Repository) -> Result> { - let mut options = status_options(); +pub fn changes(repo: &Repository, base: Option<&str>) -> Result> { + let base = match base { + Some(base) => base.to_string(), + None => default_branch(repo)?, + }; - let statuses = repo - .statuses(Some(&mut options)) - .context("Could not get statuses")?; + let committed = committed_changes(repo, &base)?.into_iter(); + let uncommitted = uncommitted_changes(repo)?.into_iter(); - statuses - .iter() - .map(|entry| Ok(entry.path().context("Could not get path")?.to_string())) - .collect() -} + let files = committed.chain(uncommitted); -fn find_tree(repo: &Repository, oid: Oid) -> Result> { - repo.find_commit(oid)? - .tree() - .with_context(|| format!("Failed to find tree for {}", oid)) + Ok(BTreeSet::from_iter(files)) } -fn find_base_oid(repo: &Repository, base: &str) -> Result { - let commit = repo - .revparse_single(base) - .and_then(|object| object.peel_to_commit()) - .with_context(|| format!("Cannot find the commit of {base}"))?; +fn default_branch(repo: &Repository) -> Result { + let config = repo.config().context("Cannot read the git config")?; - Ok(commit.id()) + 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")) + } + } + } + + Ok(DEFAULT_BRANCH.to_string()) } fn committed_changes(repo: &Repository, base: &str) -> Result> { - let base_oid = find_base_oid(repo, base)?; - - let head_oid = repo.head()?.target().context("Could not get oid")?; + let base_oid = find_commit_oid(repo, base)?; + let head_oid = find_commit_oid(repo, "HEAD")?; - 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()) } -fn default_branch(repo: &Repository) -> Result { - let config = repo.config().context("Cannot read the git config")?; - - let branch = CONFIG_PATHS - .into_iter() - .find_map(|path| config.get_string(path).ok()); - - Ok(branch.unwrap_or_else(|| DEFAULT_BRANCH.to_string())) -} +fn uncommitted_changes(repo: &Repository) -> Result> { + let statuses = repo + .statuses(Some(&mut status_options())) + .context("Cannot read the status of the working directory")?; -pub fn open_repository() -> Result { - let cwd = env::current_dir()?; + statuses + .iter() + .map(|entry| { + let path = entry + .path() + .context("A changed file has a path that is not UTF-8")?; - Repository::discover(&cwd) - .with_context(|| format!("Cannot find a repository at {}", cwd.display())) + Ok(path.to_string()) + }) + .collect() } -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(); +fn status_options() -> StatusOptions { + let mut options = StatusOptions::default(); - let files = committed.chain(uncommitted); + options.include_untracked(true); + options.recurse_untracked_dirs(true); + options.exclude_submodules(true); - Ok(BTreeSet::from_iter(files)) + options } diff --git a/src/main.rs b/src/main.rs index 646a8fd..a3cdff3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,16 +9,23 @@ use std::process; mod cli; mod git; -fn matches(file: &str, root: &Path, 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); } +} - root.join(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<()> { @@ -26,34 +33,36 @@ fn execute(options: Options) -> Result<()> { let root = repo .workdir() .context("The repository has no working directory")?; - let changes = git::changes(&repo, options.base.as_deref())?; - let separator = if options.null { '\0' } else { '\n' }; - let mut stdout = io::stdout().lock(); - for file in changes.iter().filter(|file| matches(file, root, &options)) { - write!(stdout, "{file}{separator}")?; - } + let changes = git::changes(&repo, options.base.as_deref())?; + let selected = changes + .iter() + .filter(|file| is_selected(file, root, &options)); - stdout.flush()?; + print_files(selected, options.null)?; Ok(()) } -fn is_broken_pipe(error: &anyhow::Error) -> bool { - error - .downcast_ref::() - .is_some_and(|error| error.kind() == io::ErrorKind::BrokenPipe) -} +fn is_selected(file: &str, root: &Path, 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; - } + root.join(file).exists() +} - eprintln!("{error}"); - process::exit(1); +fn print_files<'a>(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, "{file}{separator}")?; } + + stdout.flush() } diff --git a/tests/features.rs b/tests/features.rs index d1bcc62..d754b96 100644 --- a/tests/features.rs +++ b/tests/features.rs @@ -47,8 +47,7 @@ fn run_tool(world: &mut GitWorld, command: String) { #[when(expr = "the user executes the tool in {string} like: {string}")] fn run_tool_in(world: &mut GitWorld, subdirectory: String, command: String) { - let (program, args) = split_command(&command); - assert_eq!(program, "git-changed", "This step runs only git-changed"); + let args = tool_args(&command); let output = world.run_in(&subdirectory, env!("CARGO_BIN_EXE_git-changed"), &args); @@ -57,8 +56,7 @@ fn run_tool_in(world: &mut GitWorld, subdirectory: String, command: String) { #[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 (program, args) = split_command(&command); - assert_eq!(program, "git-changed", "This step runs only git-changed"); + let args = tool_args(&command); let mut child = world .command_in(".", env!("CARGO_BIN_EXE_git-changed")) @@ -146,6 +144,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}")); From 6124656ab5025d8fbfe33a42369a765fc51daa86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KARASZI=20Istv=C3=A1n?= Date: Fri, 13 Nov 2026 18:49:38 +0100 Subject: [PATCH 09/12] Test the NUL separator --- tests/features.rs | 18 +++++++++++++++++- tests/features/output.feature | 11 +++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/features.rs b/tests/features.rs index d754b96..f0e9ae1 100644 --- a/tests/features.rs +++ b/tests/features.rs @@ -82,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 { diff --git a/tests/features/output.feature b/tests/features/output.feature index 4e0ee16..d7fef62 100644 --- a/tests/features/output.feature +++ b/tests/features/output.feature @@ -24,3 +24,14 @@ Feature: Output 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 + """ From a070ad70f3096f5db75fb50b3bf554e89e7a821b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KARASZI=20Istv=C3=A1n?= Date: Fri, 13 Nov 2026 18:49:45 +0100 Subject: [PATCH 10/12] Show the cause of an error --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index a3cdff3..de08d17 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ fn main() { return; } - eprintln!("{error}"); + eprintln!("{error:#}"); process::exit(1); } } From 2a4f3f497c81855590a3040dd09c6ee2a164c360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KARASZI=20Istv=C3=A1n?= Date: Fri, 13 Nov 2026 18:55:40 +0100 Subject: [PATCH 11/12] Print the paths relative to the current directory --- README.org | 7 ++++++ src/git.rs | 29 ++++++++++++++++++---- src/main.rs | 37 +++++++++++++++++++++-------- tests/features/subdirectory.feature | 18 ++++++++++---- 4 files changed, 71 insertions(+), 20 deletions(-) diff --git a/README.org b/README.org index a0c3fc6..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: diff --git a/src/git.rs b/src/git.rs index e3839d2..12ec63a 100644 --- a/src/git.rs +++ b/src/git.rs @@ -7,16 +7,30 @@ use git2::Repository; use git2::StatusOptions; use git2::Tree; use std::collections::BTreeSet; -use std::env; +use std::path::Path; +use std::path::PathBuf; const CONFIG_PATHS: [&str; 2] = ["changed.defaultBranch", "init.defaultBranch"]; const DEFAULT_BRANCH: &str = "main"; -pub fn open_repository() -> Result { - let cwd = env::current_dir()?; +pub fn open_repository(directory: &Path) -> Result { + Repository::discover(directory) + .with_context(|| format!("Cannot find a repository at {}", directory.display())) +} + +pub fn subdirectory(repo: &Repository, directory: &Path) -> Result { + let root = repo + .workdir() + .context("The repository has no working directory")?; + + let root = canonical(root)?; + let directory = canonical(directory)?; - Repository::discover(&cwd) - .with_context(|| format!("Cannot find a repository at {}", cwd.display())) + let subdirectory = directory + .strip_prefix(&root) + .with_context(|| format!("{} is not in {}", directory.display(), root.display()))?; + + Ok(subdirectory.to_path_buf()) } pub fn changes(repo: &Repository, base: Option<&str>) -> Result> { @@ -33,6 +47,11 @@ pub fn changes(repo: &Repository, base: Option<&str>) -> Result Ok(BTreeSet::from_iter(files)) } +fn canonical(path: &Path) -> Result { + path.canonicalize() + .with_context(|| format!("Cannot resolve the path {}", path.display())) +} + fn default_branch(repo: &Repository) -> Result { let config = repo.config().context("Cannot read the git config")?; diff --git a/src/main.rs b/src/main.rs index de08d17..5d5fec3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,12 @@ use self::cli::Options; use anyhow::Context; use anyhow::Result; +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; @@ -29,23 +32,37 @@ fn is_broken_pipe(error: &anyhow::Error) -> bool { } fn execute(options: Options) -> Result<()> { - let repo = git::open_repository()?; - let root = repo - .workdir() - .context("The repository has no working directory")?; + let directory = env::current_dir().context("Cannot read the current directory")?; + let repo = git::open_repository(&directory)?; + let subdirectory = git::subdirectory(&repo, &directory)?; let changes = git::changes(&repo, options.base.as_deref())?; let selected = changes .iter() - .filter(|file| is_selected(file, root, &options)); + .map(|file| relative_path(Path::new(file), &subdirectory)) + .filter(|file| is_selected(file, &options)); print_files(selected, options.null)?; Ok(()) } -fn is_selected(file: &str, root: &Path, options: &Options) -> bool { - if !options.pattern.matches(file) { +fn relative_path(file: &Path, directory: &Path) -> PathBuf { + let mut file_parts = file.components().peekable(); + let mut directory_parts = directory.components().peekable(); + + 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; } @@ -53,15 +70,15 @@ fn is_selected(file: &str, root: &Path, options: &Options) -> bool { return true; } - root.join(file).exists() + file.exists() } -fn print_files<'a>(files: impl Iterator, null: bool) -> io::Result<()> { +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, "{file}{separator}")?; + write!(stdout, "{}{separator}", file.display())?; } stdout.flush() diff --git a/tests/features/subdirectory.feature b/tests/features/subdirectory.feature index 782a50e..bdbcfa4 100644 --- a/tests/features/subdirectory.feature +++ b/tests/features/subdirectory.feature @@ -10,16 +10,24 @@ Feature: Run the tool in a subdirectory 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 the changes of the repository + 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 """ - src/main.rs + main.rs """ - Scenario: It finds the existing files from the root of the repository - When the user executes the tool in "src" like: "git-changed -e *.md" + 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 + ../README.md """ From 726e015fa260cece69ae982cdf43bab9402f4203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KARASZI=20Istv=C3=A1n?= Date: Fri, 13 Nov 2026 19:00:16 +0100 Subject: [PATCH 12/12] Test the relative paths from a deeper subdirectory --- tests/features/subdirectory.feature | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/features/subdirectory.feature b/tests/features/subdirectory.feature index bdbcfa4..e4d74e7 100644 --- a/tests/features/subdirectory.feature +++ b/tests/features/subdirectory.feature @@ -31,3 +31,12 @@ Feature: Run the tool in a subdirectory """ ../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 + """