Skip to content
Merged
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,17 @@ pass its path explicitly:
gitlance --message-file .git/COMMIT_EDITMSG
```

### Validating Unpublished Commits

With `--not-on-remotes`, gitlance validates only the commits reachable from
`--head` that are not yet present on any remote. This makes it convenient to use
in a local `pre-push` git hook. It exits successfully when there is nothing new
to check:

```bash
gitlance --head <head_ref> --not-on-remotes
```

## Installation

### GitHub Action
Expand Down
41 changes: 20 additions & 21 deletions githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -34,35 +34,34 @@ do
continue
fi

# Determine base commit
# Run gitlance
echo "Checking git commit logs with gitlance:"
echo
if [ "$remote_oid" = "0000000000000000000000000000000000000000" ]; then
# New branch - try to find common ancestor with remote HEAD
base=""
if remote_head=$(git ls-remote "$remote" HEAD 2>/dev/null | awk '{print $1}') && [ -n "$remote_head" ]; then
base=$(git merge-base "$remote_head" "$local_oid" 2>/dev/null) || true
fi
# Fall back to root commit if no common ancestor found
if [ -z "$base" ]; then
base=$(git rev-list --max-parents=0 "$local_oid" | head -1)
# New branch: validate commits not yet present on any remote. gitlance
# derives the set from remote-tracking refs, which is more reliable than
# a single remote's HEAD (a stale remote would re-check known commits).
if ! gitlance --head "$local_oid" --not-on-remotes; then
echo ""
echo "Push rejected: Commit validation failed"
echo "To fix: git commit --amend, or git rebase -i"
echo "To bypass: git push --no-verify"
exit 1
fi
else
# Existing branch - find common ancestor to only check divergent commits
# Existing branch: check only commits that diverge from the remote.
if ! base=$(git merge-base "$remote_oid" "$local_oid"); then
echo "Error: Failed to find merge-base between remote and local commits"
echo "To bypass: git push --no-verify"
exit 1
fi
fi

# Run gitlance
echo "Checking git commit logs with gitlance:"
echo
if ! gitlance --base "$base" --head "$local_oid"; then
echo ""
echo "Push rejected: Commit validation failed"
echo "To fix: git commit --amend, or git rebase -i $base"
echo "To bypass: git push --no-verify"
exit 1
if ! gitlance --base "$base" --head "$local_oid"; then
echo ""
echo "Push rejected: Commit validation failed"
echo "To fix: git commit --amend, or git rebase -i $base"
echo "To bypass: git push --no-verify"
exit 1
fi
fi
done

Expand Down
165 changes: 155 additions & 10 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,46 @@ pub fn resolve_ref(repo: &Repository, refspec: &str) -> Result<Oid, CheckError>
})
}

/// Gets all commits in the range [base, head]
/// Returns commits from base (exclusive) to head (inclusive)
/// Lists all remote-tracking refs (`refs/remotes/*`) in the repository.
///
/// Accepts any valid git revision specification for base and head:
/// These represent everything already pushed to or fetched from any remote,
/// and are used to determine which commits are genuinely new.
pub fn remote_tracking_refs(repo: &Repository) -> Result<Vec<String>, CheckError> {
let refs = repo
.references_glob("refs/remotes/*")
.map_err(|e| CheckError::Git(format!("Failed to list remote-tracking refs: {}", e)))?;

let mut names = Vec::new();
for reference in refs {
let reference = reference
.map_err(|e| CheckError::Git(format!("Failed to read remote-tracking ref: {}", e)))?;
let name = reference
.name()
.map_err(|e| CheckError::Git(format!("Remote-tracking ref has invalid name: {}", e)))?;
names.push(name.to_string());
}

Ok(names)
}

/// Gets all commits reachable from `head` but not from any of `excludes`.
///
/// This is the core commit-selection routine. Each exclude ref is hidden from
/// the revwalk, so the result contains only commits unique to `head`. Passing a
/// single base gives a `base..head` range; passing all remote-tracking refs
/// gives the commits that are not yet on any remote.
///
/// Accepts any valid git revision specification for `head` and each exclude:
/// - Full/short SHAs, branches, tags, HEAD~n, etc.
///
/// If `skip_merge_commits` is true, merge commits (commits with more than one parent)
/// are excluded from the results.
pub fn get_commits_in_range(
pub fn get_commits_excluding(
repo: &Repository,
base: &str,
head: &str,
excludes: &[impl AsRef<str>],
skip_merge_commits: bool,
) -> Result<Vec<Commit>, CheckError> {
let base_oid = resolve_ref(repo, base)?;
let head_oid = resolve_ref(repo, head)?;

let mut revwalk = repo
Expand All @@ -89,10 +114,14 @@ pub fn get_commits_in_range(
.push(head_oid)
.map_err(|e| CheckError::Git(format!("Failed to push head to revwalk: {}", e)))?;

// Don't include the base commit itself
revwalk
.hide(base_oid)
.map_err(|e| CheckError::Git(format!("Failed to hide base in revwalk: {}", e)))?;
// Hide each exclude ref so its commits (and their ancestors) are omitted
for exclude in excludes {
let exclude = exclude.as_ref();
let exclude_oid = resolve_ref(repo, exclude)?;
revwalk.hide(exclude_oid).map_err(|e| {
CheckError::Git(format!("Failed to hide '{}' in revwalk: {}", exclude, e))
})?;
}

let mut commits = Vec::new();

Expand Down Expand Up @@ -121,6 +150,23 @@ pub fn get_commits_in_range(
Ok(commits)
}

/// Gets all commits in the range [base, head]
/// Returns commits from base (exclusive) to head (inclusive)
///
/// Accepts any valid git revision specification for base and head:
/// - Full/short SHAs, branches, tags, HEAD~n, etc.
///
/// If `skip_merge_commits` is true, merge commits (commits with more than one parent)
/// are excluded from the results.
pub fn get_commits_in_range(
repo: &Repository,
base: &str,
head: &str,
skip_merge_commits: bool,
) -> Result<Vec<Commit>, CheckError> {
get_commits_excluding(repo, head, &[base], skip_merge_commits)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -442,4 +488,103 @@ mod tests {
let commit_clone = commit.clone();
assert_eq!(commit_clone.sha, commit.sha);
}

#[test]
fn test_remote_tracking_refs_lists_remote_refs() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_repo(
temp_dir
.path()
.to_str()
.expect("Failed to convert temp dir path to string"),
);
let repo = open_repo(&repo_path).expect("Failed to open repo");

let base_sha = create_commit(&repo_path, "initial");

// Simulate a fetched remote-tracking ref by writing it directly.
run_cmd(
&repo_path,
"git",
&["update-ref", "refs/remotes/origin/main", &base_sha],
);

let refs = remote_tracking_refs(&repo).expect("Failed to list remote-tracking refs");
assert_eq!(refs, vec!["refs/remotes/origin/main".to_string()]);
}

#[test]
fn test_get_commits_excluding_empty_excludes_includes_root() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_repo(
temp_dir
.path()
.to_str()
.expect("Failed to convert temp dir path to string"),
);
let repo = open_repo(&repo_path).expect("Failed to open repo");

let _base_sha = create_commit(&repo_path, "initial");
let _sha1 = create_commit(&repo_path, "second commit");

// With no excludes, every commit reachable from head is returned,
// including the root commit that has no parent.
let commits = get_commits_excluding(&repo, "HEAD", &[] as &[&str], false)
.expect("Failed to get commits");

assert_eq!(commits.len(), 2);
assert_eq!(commits[0].message.trim(), "second commit");
assert_eq!(commits[1].message.trim(), "initial");
}

#[test]
fn test_get_commits_excluding_head_fully_excluded_is_empty() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_repo(
temp_dir
.path()
.to_str()
.expect("Failed to convert temp dir path to string"),
);
let repo = open_repo(&repo_path).expect("Failed to open repo");

let _base_sha = create_commit(&repo_path, "initial");
let _sha1 = create_commit(&repo_path, "second commit");

// Excluding head itself leaves nothing new.
let commits =
get_commits_excluding(&repo, "HEAD", &["HEAD"], false).expect("Failed to get commits");

assert!(commits.is_empty());
}

#[test]
fn test_get_commits_excluding_via_remote_tracking_refs() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_repo(
temp_dir
.path()
.to_str()
.expect("Failed to convert temp dir path to string"),
);
let repo = open_repo(&repo_path).expect("Failed to open repo");

let _base_sha = create_commit(&repo_path, "initial");
let sha1 = create_commit(&repo_path, "second commit");
let _sha2 = create_commit(&repo_path, "third commit");

// Pretend the first two commits are already published on a remote.
run_cmd(
&repo_path,
"git",
&["update-ref", "refs/remotes/origin/main", &sha1],
);

let excludes = remote_tracking_refs(&repo).expect("Failed to list remote-tracking refs");
let commits =
get_commits_excluding(&repo, "HEAD", &excludes, false).expect("Failed to get commits");

assert_eq!(commits.len(), 1);
assert_eq!(commits[0].message.trim(), "third commit");
}
}
4 changes: 3 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ pub mod output;
pub mod test_utils;

pub use error::CheckError;
pub use git::{get_commits_in_range, open_repo, resolve_ref};
pub use git::{
get_commits_excluding, get_commits_in_range, open_repo, remote_tracking_refs, resolve_ref,
};

/// Length to abbreviate SHAs in output messages
const SHA_ABBREV_LEN: usize = 8;
Expand Down
49 changes: 48 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ struct Cli {
#[arg(long, global = true)]
skip_merge_commits: bool,

/// Validate only commits not yet present on any remote (requires --head,
/// mutually exclusive with --base). Exits successfully when nothing is new.
#[arg(long, global = true, conflicts_with = "base")]
not_on_remotes: 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"])]
#[arg(long, global = true, conflicts_with_all = ["base", "head", "skip_merge_commits", "repo", "not_on_remotes"])]
message_file: Option<std::path::PathBuf>,
}

Expand Down Expand Up @@ -78,6 +83,48 @@ fn main() {
exit(1);
}
}
} else if cli.not_on_remotes {
// Validate only commits reachable from head but not from any remote.
let head = cli.head.or_else(|| std::env::var("HEAD_REF").ok());

let head = match head {
Some(h) => h,
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);
}
};

let excludes = match git::remote_tracking_refs(&repo) {
Ok(refs) => refs,
Err(e) => {
output::error(&format!("Failed to list remote-tracking refs: {}", e));
exit(1);
}
};

match git::get_commits_excluding(&repo, &head, &excludes, cli.skip_merge_commits) {
// An empty result means every commit is already on a remote, so
// there is nothing new to validate. This is a clean pass, not an
// error: the revwalk semantics make emptiness a precise signal.
Ok(commits) if commits.is_empty() => {
println!("No new commits to check.");
exit(0);
}
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());
Expand Down
Loading
Loading