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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- shell completion for bash, zsh, fish, elvish and PowerShell, including the
branches, tags and remotes of the current repository (#28)

## [0.4.0] - 2026-10-16

### Changed
Expand Down
71 changes: 71 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ version = "0.4.0"

[dependencies]
anyhow = "1.0"
clap = { version = "4.5", features = ["derive", "env"] }
clap = { version = "4.6", features = ["derive", "env"] }
clap_complete = { version = "=4.6.11", features = ["unstable-dynamic"] }
flexi_logger = "0.31"
git-url-parse = "0.6"
git2 = { version = "0.21", features = ["vendored-openssl"] }
log = "0.4"
open = "5.4"
thiserror = "2.0"

[dev-dependencies]
tempfile = "3"
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,32 @@ sudo port selfupdate
sudo port install gitweb
```

## Shell completion

`gitweb` completes its options on <kbd>TAB</kbd>, as well as the branches, tags
and remotes of the current repository (`gitweb --branch <TAB>`). Add the line
for your shell to its configuration. The completion is generated at startup, so
it stays in sync when you upgrade `gitweb`.

```sh
# Bash
echo 'source <(COMPLETE=bash gitweb)' >> ~/.bashrc

# Zsh
echo 'source <(COMPLETE=zsh gitweb)' >> ~/.zshrc

# Fish
echo 'COMPLETE=fish gitweb | source' >> ~/.config/fish/completions/gitweb.fish

# Elvish
echo 'eval (E:COMPLETE=elvish gitweb | slurp)' >> ~/.elvish/rc.elv
```

```powershell
# PowerShell
echo '$env:COMPLETE = "powershell"; gitweb | Out-String | Invoke-Expression; Remove-Item Env:\COMPLETE' >> $PROFILE
```

## Usage

`gitweb` will by default open the remote in the browser of the current
Expand Down
146 changes: 145 additions & 1 deletion src/git.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::Issue;
use anyhow::Result;
use git2::{ErrorCode, Repository};
use git2::{BranchType, ErrorCode, Repository};
use std::collections::BTreeSet;

/// Get the current repository.
// Will check that the user is in a git repository.
Expand Down Expand Up @@ -34,3 +35,146 @@ pub fn get_branch(repo: &Repository) -> String {

String::from(head.unwrap_or("master"))
}

// Get the local and remote-tracking branch names, without the remote prefix.
pub fn get_branches(repo: &Repository) -> Vec<String> {
let Ok(branches) = repo.branches(None) else {
return Vec::new();
};

let remotes = get_remotes(repo);

// A set removes the duplicates between local and remote branches and sorts
// the names.
let names: BTreeSet<String> = branches
.flatten()
.filter_map(|(branch, branch_type)| {
let name = branch.name().ok().flatten()?;

match branch_type {
BranchType::Local => Some(name.to_string()),
BranchType::Remote => remotes
.iter()
.find_map(|remote| name.strip_prefix(&format!("{}/", remote)))
.map(String::from),
}
})
.filter(|name| name != "HEAD")
.collect();

names.into_iter().collect()
}

// Get the tag names.
pub fn get_tags(repo: &Repository) -> Vec<String> {
repo.tag_names(None)
.map(|tags| tags.iter().flatten().flatten().map(String::from).collect())
.unwrap_or_default()
}

// Get the remote names.
pub fn get_remotes(repo: &Repository) -> Vec<String> {
repo.remotes()
.map(|remotes| {
remotes
.iter()
.flatten()
.flatten()
.map(String::from)
.collect()
})
.unwrap_or_default()
}

#[cfg(test)]
mod tests {
use super::*;
use git2::{Commit, RepositoryInitOptions, Signature};
use tempfile::TempDir;

// Create a repository with a single commit on the main branch.
fn init_repo() -> (TempDir, Repository) {
let dir = tempfile::tempdir().unwrap();
let repo = Repository::init_opts(
dir.path(),
RepositoryInitOptions::new().initial_head("main"),
)
.unwrap();

{
let signature = Signature::now("gitweb", "gitweb@example.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &signature, &signature, "init", &tree, &[])
.unwrap();
}

(dir, repo)
}

fn head_commit(repo: &Repository) -> Commit<'_> {
repo.head().unwrap().peel_to_commit().unwrap()
}

#[test]
fn test_get_branches_merges_local_and_remote_branches() {
let (_dir, repo) = init_repo();
let commit = head_commit(&repo);

repo.branch("local-only", &commit, false).unwrap();
repo.remote("origin", "git@github.com:yoannfleurydev/gitweb.git")
.unwrap();
repo.reference("refs/remotes/origin/main", commit.id(), false, "")
.unwrap();
repo.reference("refs/remotes/origin/remote-only", commit.id(), false, "")
.unwrap();
repo.reference_symbolic(
"refs/remotes/origin/HEAD",
"refs/remotes/origin/main",
false,
"",
)
.unwrap();

assert_eq!(
get_branches(&repo),
vec!["local-only", "main", "remote-only"]
);
}

#[test]
fn test_get_branches_without_commit() {
let dir = tempfile::tempdir().unwrap();
let repo = Repository::init(dir.path()).unwrap();

assert!(get_branches(&repo).is_empty());
}

#[test]
fn test_get_tags() {
let (_dir, repo) = init_repo();
let commit = head_commit(&repo);

repo.tag_lightweight("v0.1.0", commit.as_object(), false)
.unwrap();
repo.tag_lightweight("v0.2.0", commit.as_object(), false)
.unwrap();

let mut tags = get_tags(&repo);
tags.sort();

assert_eq!(tags, vec!["v0.1.0", "v0.2.0"]);
}

#[test]
fn test_get_remotes() {
let (_dir, repo) = init_repo();

repo.remote("upstream", "git@github.com:yoannfleurydev/gitweb.git")
.unwrap();
repo.remote("origin", "git@github.com:someone/gitweb.git")
.unwrap();

assert_eq!(get_remotes(&repo), vec!["origin", "upstream"]);
}
}
7 changes: 6 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use clap::Parser;
use clap::{CommandFactory, Parser};
use clap_complete::CompleteEnv;
use flexi_logger::Logger;
use gitweb::options::Opt;
use gitweb::run;
Expand All @@ -8,6 +9,10 @@ use std::process::exit;
extern crate log;

fn main() {
// Answer the shell completion requests (COMPLETE=<shell>) before anything
// else, as nothing must be written to the output before.
CompleteEnv::with_factory(Opt::command).complete();

// Get the command line options.
let opt = Opt::parse();

Expand Down
Loading
Loading