diff --git a/CHANGELOG.md b/CHANGELOG.md
index d489420..7e23102 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
All notable changes to this project will be documented in this file.
+## [v1.61.0]
+- Fixed local filesystem scans to keep `open_path_as_is` enabled when opening Git repositories and only disable it for diff-based scans.
+- Created Linux and Windows specific installer script
+- Updated diff-focused scanning so `--branch-root-commit` can be provided alongside `--branch`, letting you diff from a chosen commit while targeting a specific branch tip (still defaulting back to the `--branch` ref when the commit is omitted).
+- Updated rules
+
## [v1.60.0]
- Removed the `--bitbucket-username`, `--bitbucket-token`, and `--bitbucket-oauth-token` flags in favour of `KF_BITBUCKET_*` environment variables when authenticating to Bitbucket.
- Added provider-specific `kingfisher scan` subcommands (for example `kingfisher scan github …`) that translate into the legacy flags under the hood. The new layout keeps backwards compatibility while removing the wall of provider options from `kingfisher scan --help`.
diff --git a/Cargo.toml b/Cargo.toml
index 94c2e3f..d85f76f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,7 +10,7 @@ publish = false
[package]
name = "kingfisher"
-version = "1.60.0"
+version = "1.61.0"
description = "MongoDB's blazingly fast and accurate secret scanning and validation tool"
edition.workspace = true
rust-version.workspace = true
diff --git a/README.md b/README.md
index 3a73552..085be3b 100644
--- a/README.md
+++ b/README.md
@@ -166,17 +166,23 @@ brew install kingfisher
-You can easily install using [ubi](https://github.com/houseabsolute/ubi), which downloads the correct binary for your platform.
+Use the bundled installer script to fetch the latest release and place it in
+`~/.local/bin` (or a directory of your choice):
```bash
# Linux, macOS
curl --silent --location \
- https://raw.githubusercontent.com/houseabsolute/ubi/master/bootstrap/bootstrap-ubi.sh | \
- sh && \
- ubi --project mongodb/kingfisher --in "$HOME/.local/bin"
+ https://raw.githubusercontent.com/mongodb/kingfisher/main/scripts/install-kingfisher.sh | \
+ bash
```
-This installs and runs `ubi` and then places the `kingfisher` executable in `~/.local/bin` on Unix-like systems.
+To install into a custom location, pass the desired directory as an argument:
+
+```bash
+curl --silent --location \
+ https://raw.githubusercontent.com/mongodb/kingfisher/main/scripts/install-kingfisher.sh | \
+ bash -s -- /opt/kingfisher
+```
@@ -184,14 +190,21 @@ This installs and runs `ubi` and then places the `kingfisher` executable in `~/.
-You can easily install using [ubi](https://github.com/houseabsolute/ubi), which downloads the correct binary for your platform.
+Download and run the PowerShell installer to place the binary in
+`$env:USERPROFILE\bin` (or another directory you specify):
```powershell
# Windows
-powershell -exec bypass -c "Invoke-WebRequest -URI 'https://raw.githubusercontent.com/houseabsolute/ubi/master/bootstrap/bootstrap-ubi.ps1' -UseBasicParsing | Invoke-Expression" && ubi --project mongodb/kingfisher --in .
+Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
+Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/mongodb/kingfisher/main/scripts/install-kingfisher.ps1' -OutFile install-kingfisher.ps1
+./install-kingfisher.ps1
```
-This installs and runs `ubi` and then places the `kingfisher` executable in the current directory on Windows.
+You can provide a custom destination using the `-InstallDir` parameter:
+
+```powershell
+./install-kingfisher.ps1 -InstallDir 'C:\Tools\Kingfisher'
+```
@@ -415,6 +428,11 @@ kingfisher scan ./my-project \
Limit scanning to the delta between your default branch and a pull request branch by combining `--since-commit` with `--branch` (defaults to `HEAD`). This only scans files that differ between the two references, which keeps CI runs fast while still blocking new secrets.
+Use `--branch-root-commit` alongside `--branch` when you need to include a specific commit (and everything after it) in a diff-focused scan without re-examining earlier history. Provide the branch tip (or other comparison ref) via `--branch`, and pass the commit or merge-base you want to include with `--branch-root-commit`. If you omit `--branch-root-commit`, you can still enable `--branch-root` to fall back to treating the `--branch` ref itself as the inclusive root for backwards compatibility. This is especially useful in long-lived branches where you want to resume scanning from a previous review point or from the commit where a hotfix forked.
+
+> **How is this different from `--since-commit`?**
+> `--since-commit` computes a diff between the branch tip and another ref, so it only inspects files that changed between those two points in history. `--branch-root-commit` rewinds to the parent of the commit you provide and then scans everything introduced from that commit forward, even if the files are unchanged relative to another baseline. Reach for `--since-commit` to keep CI scans fast by checking only the latest delta, and use `--branch-root-commit` when you want to re-audit the full contents of a branch starting at a specific commit.
+
```bash
kingfisher scan . \
--since-commit origin/main \
@@ -434,8 +452,21 @@ kingfisher scan /tmp/SecretsTest --branch feature-1 \
--since-commit=$(git -C /tmp/SecretsTest merge-base main feature-1)
#
# scan only a specific commit
-kingfisher scan /tmp/dev/SecretsTest \
+kingfisher scan /tmp/SecretsTest \
--branch baba6ccb453963d3f6136d1ace843e48d7007c3f
+#
+# scan feature-1 starting at a specific commit (inclusive)
+kingfisher scan /tmp/SecretsTest --branch feature-1 \
+ --branch-root-commit baba6ccb453963d3f6136d1ace843e48d7007c3f
+#
+# scan feature-1 starting from the commit where the branch diverged from main
+kingfisher scan /tmp/SecretsTest --branch feature-1 \
+ --branch-root-commit $(git -C /tmp/SecretsTest merge-base main feature-1)
+#
+# scan from a hotfix commit that should be re-checked before merging
+HOTFIX_COMMIT=$(git -C /tmp/SecretsTest rev-parse hotfix~1)
+kingfisher scan /tmp/SecretsTest --branch hotfix \
+ --branch-root-commit "$HOTFIX_COMMIT"
```
When the branch under test is already checked out, `--branch HEAD` or omitting `--branch` entirely is sufficient. Kingfisher exits with `200` when any findings are discovered and `205` when validated secrets are present, allowing CI jobs to fail automatically if new credentials slip in.
diff --git a/data/rules/azurestorage.yml b/data/rules/azurestorage.yml
index 3313d8b..aea15a9 100644
--- a/data/rules/azurestorage.yml
+++ b/data/rules/azurestorage.yml
@@ -4,27 +4,26 @@ rules:
pattern: |
(?xi)
(?:
- \b
- azure
- (?:.|[\n\r]){0,32}?
- (?i:
- (?:Account|Storage)
- (?:[._-]Account)?
- [._-]?Name
- )
- (?:.|[\n\r]){0,20}?
- ([a-z0-9]{3,24})
+ # A) Connection string: AccountName=
+ (?i:AccountName)\s*=\s*([a-z0-9]{3,24})(?:\b|[^a-z0-9])
+
|
- ([a-z0-9]{3,24})
- (?i:\.blob\.core\.windows\.net)
- )\b
- min_entropy: 2.5
+ # B) Blob endpoint URL: .blob.core.windows.net
+ ([a-z0-9]{3,24})\.blob\.core\.windows\.net\b
+
+ |
+ # C) Explicit KV labels near 'azure storage/account name' with tight separators
+ \bazure(?:[_\s-]*)(?:storage|account)(?:[_\s-]*)(?:name)\b
+ [\s:=\"']{0,6}
+ ([a-z0-9]{3,24})(?:\b|[^a-z0-9])
+ )
+ min_entropy: 2.0
visible: false
confidence: medium
examples:
- - azure_storage_name=mystorageaccount123
+ - AccountName=mystorageaccount
- mystorageaccount.blob.core.windows.net
-
+ - azure_storage_name="prodblob2024"
- name: Azure Storage Account Key
id: kingfisher.azurestorage.2
pattern: |
@@ -45,4 +44,4 @@ rules:
type: AzureStorage
depends_on_rule:
- rule_id: kingfisher.azurestorage.1
- variable: AZURENAME
+ variable: AZURENAME
\ No newline at end of file
diff --git a/data/rules/gitlab.yml b/data/rules/gitlab.yml
index c7475d6..1cdf48c 100644
--- a/data/rules/gitlab.yml
+++ b/data/rules/gitlab.yml
@@ -3,12 +3,11 @@ rules:
id: kingfisher.gitlab.1
pattern: |
(?xi)
- \b
- (
+ \b
+ (
glpat-
[0-9A-Z_-]{20}
- )
- (?:\b|$)
+ )
min_entropy: 3.5
confidence: medium
examples:
@@ -114,4 +113,32 @@ rules:
- '"token is missing"'
- '"403 Forbidden"'
negative: true
- url: https://gitlab.com/api/v4/ci/pipeline_triggers/{{ TOKEN }}
\ No newline at end of file
+ url: https://gitlab.com/api/v4/ci/pipeline_triggers/{{ TOKEN }}
+ - name: GitLab Private Token - Updated Format
+ id: kingfisher.gitlab.4
+ pattern: |
+ (?x)
+ \b
+ (
+ glpat-[A-Za-z0-9_-]{36,38}\.01\.[a-z0-9]{9}
+ )
+ min_entropy: 3.5
+ confidence: medium
+ examples:
+ - glpat-5m8CwMZi4bwlRSCKzG0-3W86MQp1OmV5Y2UK.01.1012mzo24
+ references:
+ - https://github.com/diffblue/gitlab/blob/39c63ee83369bf5353256a6b95f3116728edd102/doc/api/personal_access_tokens.md
+ - https://docs.gitlab.com/api/personal_access_tokens/
+ validation:
+ type: Http
+ content:
+ request:
+ headers:
+ PRIVATE-TOKEN: '{{ TOKEN }}'
+ method: GET
+ response_matcher:
+ - report_response: true
+ - type: WordMatch
+ words:
+ - '"id"'
+ url: https://gitlab.com/api/v4/personal_access_tokens/self
\ No newline at end of file
diff --git a/data/rules/vercel.yml b/data/rules/vercel.yml
index d649b00..121b5fe 100644
--- a/data/rules/vercel.yml
+++ b/data/rules/vercel.yml
@@ -8,7 +8,7 @@ rules:
(?:.|[\n\r]){0,32}?
\b
(
- [a-zA-Z0-9]{24}
+ [A-Z0-9]{24}
)
\b
confidence: medium
diff --git a/scripts/install-kingfisher.ps1 b/scripts/install-kingfisher.ps1
new file mode 100644
index 0000000..5e2405c
--- /dev/null
+++ b/scripts/install-kingfisher.ps1
@@ -0,0 +1,80 @@
+<#
+.SYNOPSIS
+ Download and install the latest Kingfisher release for Windows.
+
+.DESCRIPTION
+ Fetches the most recent GitHub release for mongodb/kingfisher, downloads the
+ Windows x64 archive, and extracts kingfisher.exe to the destination folder.
+ By default the script installs into "$env:USERPROFILE\bin".
+
+.PARAMETER InstallDir
+ Optional destination directory for the kingfisher.exe binary.
+
+.EXAMPLE
+ ./install-kingfisher.ps1
+
+.EXAMPLE
+ ./install-kingfisher.ps1 -InstallDir "C:\\Tools"
+#>
+param(
+ [Parameter(Position = 0)]
+ [string]$InstallDir = (Join-Path $env:USERPROFILE 'bin')
+)
+
+$repo = 'mongodb/kingfisher'
+$apiUrl = "https://api.github.com/repos/$repo/releases/latest"
+$assetName = 'kingfisher-windows-x64.zip'
+
+if (-not (Get-Command Invoke-WebRequest -ErrorAction SilentlyContinue)) {
+ throw 'Invoke-WebRequest is required to download releases.'
+}
+
+if (-not (Get-Command Expand-Archive -ErrorAction SilentlyContinue)) {
+ throw 'Expand-Archive is required to extract the release archive. Install the PowerShell archive module.'
+}
+
+Write-Host "Fetching latest release metadata for $repo…"
+try {
+ $response = Invoke-WebRequest -Uri $apiUrl -UseBasicParsing
+ $release = $response.Content | ConvertFrom-Json
+} catch {
+ throw "Failed to retrieve release information from GitHub: $_"
+}
+
+$releaseTag = $release.tag_name
+$asset = $release.assets | Where-Object { $_.name -eq $assetName }
+if (-not $asset) {
+ throw "Could not find asset '$assetName' in the latest release."
+}
+
+$tempDir = New-Item -ItemType Directory -Path ([System.IO.Path]::GetTempPath()) -Name ([System.Guid]::NewGuid().ToString())
+$archivePath = Join-Path $tempDir.FullName $assetName
+
+try {
+ if ($releaseTag) {
+ Write-Host "Latest release: $releaseTag"
+ }
+
+ Write-Host "Downloading $assetName…"
+ Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $archivePath -UseBasicParsing
+
+ Write-Host 'Extracting archive…'
+ Expand-Archive -Path $archivePath -DestinationPath $tempDir.FullName -Force
+
+ $binaryPath = Join-Path $tempDir.FullName 'kingfisher.exe'
+ if (-not (Test-Path $binaryPath)) {
+ throw 'Extracted archive did not contain kingfisher.exe.'
+ }
+
+ New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
+ $destination = Join-Path $InstallDir 'kingfisher.exe'
+ Copy-Item -Path $binaryPath -Destination $destination -Force
+
+ Write-Host "Kingfisher installed to: $destination"
+ Write-Host "Ensure '$InstallDir' is in your PATH environment variable."
+}
+finally {
+ if ($tempDir -and (Test-Path $tempDir.FullName)) {
+ Remove-Item -Path $tempDir.FullName -Recurse -Force
+ }
+}
diff --git a/scripts/install-kingfisher.sh b/scripts/install-kingfisher.sh
new file mode 100755
index 0000000..4bb2386
--- /dev/null
+++ b/scripts/install-kingfisher.sh
@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO="mongodb/kingfisher"
+DEFAULT_INSTALL_DIR="$HOME/.local/bin"
+LATEST_DL_BASE="https://github.com/${REPO}/releases/latest/download"
+
+usage() {
+ cat <<'USAGE'
+Usage: install-kingfisher.sh [INSTALL_DIR]
+
+Downloads the latest Kingfisher release for Linux or macOS and installs the
+binary into INSTALL_DIR (default: ~/.local/bin).
+
+Requirements: curl, tar
+USAGE
+}
+
+if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
+ usage
+ exit 0
+fi
+
+INSTALL_DIR="${1:-$DEFAULT_INSTALL_DIR}"
+
+# deps
+command -v curl >/dev/null 2>&1 || { echo "Error: curl is required." >&2; exit 1; }
+command -v tar >/dev/null 2>&1 || { echo "Error: tar is required." >&2; exit 1; }
+
+OS="$(uname -s)"
+ARCH="$(uname -m)"
+
+case "$OS" in
+ Linux) platform="linux" ;;
+ Darwin) platform="darwin" ;;
+ *) echo "Error: Unsupported OS '$OS' (Linux/macOS only)." >&2; exit 1 ;;
+esac
+
+case "$ARCH" in
+ x86_64|amd64) arch_suffix="x64" ;;
+ arm64|aarch64) arch_suffix="arm64" ;;
+ *) echo "Error: Unsupported arch '$ARCH' (x86_64/amd64, arm64/aarch64 only)." >&2; exit 1 ;;
+esac
+
+asset_name="kingfisher-${platform}-${arch_suffix}.tgz"
+: "${asset_name:?internal error: asset_name not set}" # guard for set -u
+
+download_url="${LATEST_DL_BASE}/${asset_name}"
+
+tmpdir="$(mktemp -d)"
+cleanup() { rm -rf "$tmpdir"; }
+trap cleanup EXIT
+
+archive_path="$tmpdir/$asset_name"
+
+echo "Downloading latest: ${asset_name} …"
+# -f: fail on HTTP errors (e.g., 404 if asset missing)
+if ! curl -fLsS "${download_url}" -o "$archive_path"; then
+ echo "Error: Failed to download ${download_url}" >&2
+ echo "Tip: Ensure the release includes '${asset_name}'." >&2
+ exit 1
+fi
+
+echo "Extracting archive…"
+tar -C "$tmpdir" -xzf "$archive_path"
+
+if [[ ! -f "$tmpdir/kingfisher" ]]; then
+ echo "Error: Extracted archive did not contain the 'kingfisher' binary." >&2
+ exit 1
+fi
+
+mkdir -p "$INSTALL_DIR"
+install -m 0755 "$tmpdir/kingfisher" "$INSTALL_DIR/kingfisher"
+
+printf 'Kingfisher installed to: %s/kingfisher\n\n' "$INSTALL_DIR"
+if ! command -v kingfisher >/dev/null 2>&1; then
+ printf 'Add this to your shell config if %s is not on PATH:\n export PATH="%s:$PATH"\n' "$INSTALL_DIR" "$INSTALL_DIR"
+fi
diff --git a/src/baseline.rs b/src/baseline.rs
index 7616dd5..6f3dab5 100644
--- a/src/baseline.rs
+++ b/src/baseline.rs
@@ -10,7 +10,7 @@ use chrono::Local;
use serde::{Deserialize, Serialize};
use tracing::debug;
-use crate::{findings_store::FindingsStore, matcher::compute_finding_fingerprint};
+use crate::findings_store::FindingsStore;
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct BaselineFile {
@@ -53,20 +53,6 @@ fn normalize_path(p: &Path, roots: &[PathBuf]) -> String {
p.to_string_lossy().replace('\\', "/")
}
-fn compute_hash(secret: &str, path: &str) -> String {
- let fp = compute_finding_fingerprint(secret, path, 0, 0);
- format!("{:016x}", fp)
-}
-
-fn extract_secret(m: &crate::matcher::Match) -> String {
- m.groups
- .captures
- .get(1)
- .or_else(|| m.groups.captures.get(0))
- .map(|c| c.value.to_string())
- .unwrap_or_default()
-}
-
pub fn apply_baseline(
store: &mut FindingsStore,
baseline_path: &Path,
@@ -87,10 +73,10 @@ pub fn apply_baseline(
for arc_msg in store.get_matches_mut() {
let (origin, _blob, m) = Arc::make_mut(arc_msg);
let file_path = origin.iter().filter_map(|o| o.full_path()).next();
+ let hash = format!("{:016x}", m.finding_fingerprint);
+
if let Some(fp) = file_path {
let normalized = normalize_path(&fp, roots);
- let secret = extract_secret(m);
- let hash = compute_hash(&secret, &normalized);
if known.contains(&hash) {
debug!("Skipping {} due to baseline (hash {})", normalized, hash);
m.visible = false;
@@ -108,6 +94,11 @@ pub fn apply_baseline(
};
new_entries.push(entry);
}
+ } else if known.contains(&hash) {
+ m.visible = false;
+ if manage {
+ encountered.insert(hash.clone());
+ }
}
}
if manage {
@@ -127,3 +118,136 @@ pub fn apply_baseline(
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{
+ blob::{BlobId, BlobMetadata},
+ location::{Location, OffsetSpan, SourcePoint, SourceSpan},
+ matcher::{Match, SerializableCapture, SerializableCaptures},
+ origin::{Origin, OriginSet},
+ rules::rule::{Confidence, Rule, RuleSyntax},
+ };
+ use anyhow::Result;
+ use smallvec::SmallVec;
+ use std::{path::Path, sync::Arc};
+ use tempfile::TempDir;
+
+ fn test_rule() -> Arc {
+ Arc::new(Rule::new(RuleSyntax {
+ name: "test".to_string(),
+ id: "test.rule".to_string(),
+ pattern: "test".to_string(),
+ min_entropy: 0.0,
+ confidence: Confidence::Low,
+ visible: true,
+ examples: vec![],
+ negative_examples: vec![],
+ references: vec![],
+ validation: None,
+ depends_on_rule: vec![],
+ }))
+ }
+
+ fn empty_captures() -> SerializableCaptures {
+ SerializableCaptures { captures: SmallVec::<[SerializableCapture; 2]>::new() }
+ }
+
+ fn make_store_with_match(fingerprint: u64, file_path: &Path) -> FindingsStore {
+ let mut store = FindingsStore::new(PathBuf::from("."));
+ let rule = test_rule();
+ let match_item = Match {
+ location: Location {
+ offset_span: OffsetSpan { start: 0, end: 1 },
+ source_span: SourceSpan {
+ start: SourcePoint { line: 1, column: 0 },
+ end: SourcePoint { line: 1, column: 1 },
+ },
+ },
+ groups: empty_captures(),
+ blob_id: BlobId::default(),
+ finding_fingerprint: fingerprint,
+ rule: Arc::clone(&rule),
+ validation_response_body: String::new(),
+ validation_response_status: 0,
+ validation_success: false,
+ calculated_entropy: 0.0,
+ visible: true,
+ is_base64: false,
+ };
+
+ let origin = OriginSet::from(Origin::from_file(file_path.to_path_buf()));
+ let blob_meta = Arc::new(BlobMetadata {
+ id: BlobId::default(),
+ num_bytes: 0,
+ mime_essence: None,
+ language: None,
+ });
+
+ let entry = Arc::new((Arc::new(origin), blob_meta, match_item));
+ store.get_matches_mut().push(entry);
+ store
+ }
+
+ fn expected_relative_path(root: &Path, file: &Path) -> String {
+ let mut expected = PathBuf::from(root.file_name().unwrap());
+ if let Ok(stripped) = file.strip_prefix(root) {
+ expected = expected.join(stripped);
+ }
+ expected.to_string_lossy().replace('\\', "/")
+ }
+
+ #[test]
+ fn apply_baseline_filters_existing_fingerprints() -> Result<()> {
+ let tmp = TempDir::new()?;
+ let roots = [tmp.path().to_path_buf()];
+ let secret_file = tmp.path().join("secret.txt");
+ fs::write(&secret_file, "dummy")?;
+ let baseline_path = tmp.path().join("baseline.yaml");
+ let fingerprint = 0x1234_u64;
+
+ let mut store = make_store_with_match(fingerprint, &secret_file);
+ apply_baseline(&mut store, &baseline_path, true, &roots)?;
+
+ let baseline = load_baseline(&baseline_path)?;
+ assert_eq!(baseline.exact_findings.matches.len(), 1);
+ let entry = &baseline.exact_findings.matches[0];
+ assert_eq!(entry.fingerprint, format!("{:016x}", fingerprint));
+ assert_eq!(entry.filepath, expected_relative_path(roots[0].as_path(), &secret_file));
+
+ let (_, _, recorded) = store.get_matches()[0].as_ref();
+ assert!(recorded.visible);
+
+ let mut follow_up = make_store_with_match(fingerprint, &secret_file);
+ apply_baseline(&mut follow_up, &baseline_path, false, &roots)?;
+ let (_, _, filtered) = follow_up.get_matches()[0].as_ref();
+ assert!(!filtered.visible);
+
+ Ok(())
+ }
+
+ #[test]
+ fn managing_baseline_is_idempotent() -> Result<()> {
+ let tmp = TempDir::new()?;
+ let roots = [tmp.path().to_path_buf()];
+ let secret_file = tmp.path().join("secret.txt");
+ fs::write(&secret_file, "dummy")?;
+ let baseline_path = tmp.path().join("baseline.yaml");
+ let fingerprint = 0xfeed_beef_dade_f00d_u64;
+
+ let mut initial = make_store_with_match(fingerprint, &secret_file);
+ apply_baseline(&mut initial, &baseline_path, true, &roots)?;
+ let baseline_before = fs::read_to_string(&baseline_path)?;
+
+ let mut rerun = make_store_with_match(fingerprint, &secret_file);
+ apply_baseline(&mut rerun, &baseline_path, true, &roots)?;
+ let baseline_after = fs::read_to_string(&baseline_path)?;
+ assert_eq!(baseline_before, baseline_after);
+
+ let (_, _, suppressed) = rerun.get_matches()[0].as_ref();
+ assert!(!suppressed.visible);
+
+ Ok(())
+ }
+}
diff --git a/src/cli/commands/inputs.rs b/src/cli/commands/inputs.rs
index fdea286..a04785e 100644
--- a/src/cli/commands/inputs.rs
+++ b/src/cli/commands/inputs.rs
@@ -332,6 +332,32 @@ pub struct InputSpecifierArgs {
visible_alias = "ref"
)]
pub branch: Option,
+
+ /// Treat the `--branch` commit or ref as the inclusive root for the scan.
+ ///
+ /// When enabled, Kingfisher diffs from the parent of the selected commit
+ /// through the current HEAD of the repository, ensuring the chosen commit
+ /// and every descendant is scanned exactly once. Providing
+ /// `--branch-root-commit` will also enable this behaviour automatically.
+ #[arg(
+ long = "branch-root",
+ help_heading = "Git Options",
+ requires = "branch",
+ conflicts_with = "since_commit",
+ action = clap::ArgAction::SetTrue
+ )]
+ pub branch_root: bool,
+
+ /// Explicit commit or ref to use as the inclusive branch root. Supplying
+ /// this flag implicitly enables branch-root scanning even if `--branch-root`
+ /// is omitted.
+ #[arg(
+ long = "branch-root-commit",
+ value_name = "GIT-REF",
+ help_heading = "Git Options",
+ conflicts_with = "since_commit"
+ )]
+ pub branch_root_commit: Option,
}
impl InputSpecifierArgs {
diff --git a/src/lib.rs b/src/lib.rs
index fb9246c..fcbff87 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -62,6 +62,7 @@ use tracing::debug;
pub struct GitDiffConfig {
pub since_ref: Option,
pub branch_ref: String,
+ pub branch_root: Option,
}
struct EnumeratorConfig {
@@ -332,7 +333,16 @@ impl FilesystemEnumerator {
/// Opens the given Git repository if it exists, returning None if not.
pub fn open_git_repo(path: &Path) -> Result