Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,31 @@ steps:
run: exit 1
```

## CLI Usage

gitlance can also be run directly as a command-line tool, independent of GitHub
Actions.

### Validating a Single Commit Message

Instead of a commit range, gitlance can validate a single commit message read
from a file, using `--message-file`. This makes it convenient to use in a local
`commit-msg` git hook, where git passes the path to the prepared message as the
first argument (`$1`):

```bash
#!/bin/sh
# .git/hooks/commit-msg
gitlance --message-file "$1"
```

To validate a message file directly (for example the last prepared message),
pass its path explicitly:

```bash
gitlance --message-file .git/COMMIT_EDITMSG
```

## Installation

### GitHub Action
Expand Down
2 changes: 2 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ tasks:
cmds:
- cp githooks/pre-push "{{.HOOKS_DIR}}/"
- chmod +x "{{.HOOKS_DIR}}/pre-push"
- cp githooks/commit-msg "{{.HOOKS_DIR}}/"
- chmod +x "{{.HOOKS_DIR}}/commit-msg"
- echo "✓ Git hooks installed to {{.HOOKS_DIR}}"

setup:
Expand Down
25 changes: 25 additions & 0 deletions githooks/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/bash

# SPDX-FileCopyrightText: Canonical Ltd.
#
# SPDX-License-Identifier: Apache-2.0

set -e

# Check that gitlance is available
if ! command -v gitlance >/dev/null 2>&1; then
echo "Error: gitlance is not installed or not in PATH"
echo "To bypass: git commit --no-verify"
exit 1
fi

message_file="$1"

if ! gitlance --message-file "$message_file"; then
echo
echo "Commit rejected: Message validation failed"
echo "To bypass: git commit --no-verify"
exit 1
fi

exit 0
65 changes: 65 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,34 @@ pub struct Commit {
pub message: String,
}

impl Commit {
/// Creates a Commit from a raw commit message (no SHA available).
///
/// Comment lines are stripped, matching how git treats a commit message.
pub fn from_message(message: String) -> Self {
Self {
sha: "-".to_string(),
message: strip_comments(&message),
}
}
}

/// The git "scissors" line. Everything from this line onward is discarded by
/// git when interpreting a commit message (e.g. with `commit --verbose`).
const SCISSORS: &str = "# ------------------------ >8 ------------------------";

/// Strips content the way git interprets a commit message: everything from the
/// scissors line onward is dropped, along with any comment lines (starting with
/// `#`, ignoring leading whitespace).
fn strip_comments(message: &str) -> String {
message
.lines()
.take_while(|line| line.trim_start() != SCISSORS)
.filter(|line| !line.trim_start().starts_with('#'))
.collect::<Vec<_>>()
.join("\n")
}

/// Opens a git repository at the specified path
pub fn open_repo(repo_path: &str) -> Result<Repository, CheckError> {
Repository::open(repo_path)
Expand Down Expand Up @@ -99,6 +127,43 @@ mod tests {
use crate::test_utils::*;
use tempfile::TempDir;

#[test]
fn test_strip_comments_hash_not_at_line_start_is_kept() {
assert_eq!(strip_comments("fix: issue #42"), "fix: issue #42");
}

#[test]
fn test_strip_comments_indented_hash_is_comment() {
// Git treats leading-whitespace `#` lines as comments too.
assert_eq!(strip_comments("feat: x\n # indented"), "feat: x");
}

#[test]
fn test_strip_comments_all_comments_yields_empty() {
assert_eq!(strip_comments("# only\n# comments"), "");
}

#[test]
fn test_strip_comments_drops_content_after_scissors() {
let raw = "feat: subject\n\nBody\n# ------------------------ >8 ------------------------\ndiff --git a/x b/x\nWIP leftover";
assert_eq!(strip_comments(raw), "feat: subject\n\nBody");
}

#[test]
fn test_strip_comments_indented_scissors_is_honored() {
let raw =
"feat: subject\n # ------------------------ >8 ------------------------\nignored";
assert_eq!(strip_comments(raw), "feat: subject");
}

#[test]
fn test_from_message_strips_comment_lines() {
let raw = "# Please enter the commit message\nfeat: subject\n\n# a comment\nBody\n";
let commit = Commit::from_message(raw.to_string());
assert_eq!(commit.sha, "-");
assert_eq!(commit.message, "feat: subject\n\nBody");
}

#[test]
fn test_open_repo_success() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
Expand Down
103 changes: 54 additions & 49 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ struct Cli {
head: Option<String>,

/// Git repository path
#[arg(long, global = true, default_value = ".")]
repo: String,
#[arg(long, global = true)]
repo: Option<String>,

/// Skip merge commits in validation (default: false, all commits are checked)
#[arg(long, global = true)]
skip_merge_commits: bool,

/// Validate a single commit message from file (e.g. .git/COMMIT_EDITMSG)
#[arg(long, global = true, conflicts_with_all = ["base", "head", "skip_merge_commits", "repo"])]
message_file: Option<std::path::PathBuf>,
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -65,57 +69,58 @@ fn main() {
// Determine which checks to run
let command = cli.command.unwrap_or(Commands::All);

// Get refs, with environment variable fallback
let base = cli.base.or_else(|| std::env::var("BASE_REF").ok());
let head = cli.head.or_else(|| std::env::var("HEAD_REF").ok());

// Validate both refs before proceeding
let mut has_errors = false;

if base.is_none() {
output::error(
"Missing base reference. Provide --base or set BASE_REF environment variable",
);
has_errors = true;
}

if head.is_none() {
output::error(
"Missing head reference. Provide --head or set HEAD_REF environment variable",
);
has_errors = true;
}

if has_errors {
exit(1);
}

let base = base.expect("base should be validated above");
let head = head.expect("head should be validated above");

// Open repository
let repo = match git::open_repo(&cli.repo) {
Ok(r) => r,
Err(e) => {
output::error(&format!("Failed to open repository: {}", e));
exit(1);
// Get commits based on input mode
let commits = if let Some(path) = cli.message_file {
match std::fs::read_to_string(&path) {
Ok(message) => vec![git::Commit::from_message(message)],
Err(e) => {
output::error(&format!("Failed to read '{}': {}", path.display(), e));
exit(1);
}
}
};

// Get commits in range
let commits = match git::get_commits_in_range(&repo, &base, &head, cli.skip_merge_commits) {
Ok(commits) => commits,
Err(e) => {
output::error(&format!("Failed to get commits: {}", e));
exit(1);
} else {
let base = cli.base.or_else(|| std::env::var("BASE_REF").ok());
let head = cli.head.or_else(|| std::env::var("HEAD_REF").ok());

let (base, head) = match (base, head) {
(Some(b), Some(h)) => (b, h),
(None, None) => {
output::error(
"Provide --base and --head (or set BASE_REF/HEAD_REF), or use --message-file",
);
exit(1);
}
(None, Some(_)) => {
output::error("Missing --base (or BASE_REF)");
exit(1);
}
(Some(_), None) => {
output::error("Missing --head (or HEAD_REF)");
exit(1);
}
};

let repo = match git::open_repo(cli.repo.as_deref().unwrap_or(".")) {
Ok(r) => r,
Err(e) => {
output::error(&format!("Failed to open repository: {}", e));
exit(1);
}
};

match git::get_commits_in_range(&repo, &base, &head, cli.skip_merge_commits) {
Ok(commits) if commits.is_empty() => {
output::notice("No commits found in the specified range");
exit(0);
}
Comment thread
agherzan marked this conversation as resolved.
Ok(commits) => commits,
Err(e) => {
output::error(&format!("Failed to get commits: {}", e));
exit(1);
}
}
};

if commits.is_empty() {
output::notice("No commits found in the specified range");
exit(0);
}

// Display the commits being tested
display_commits(&commits);

Expand Down
Loading
Loading