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
10 changes: 6 additions & 4 deletions .claude/skills/bflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: ALWAYS load this skill if you interact with GIT branches! This skil

## Hard Rule

**NEVER** use `git branch`, `git merge`, `git tag`, `gh pr create`, or any other raw git/gh command for branch lifecycle operations. **ALL** branch creation, merging, tagging, PR creation, and version bumping MUST go through `bflow`.
**NEVER** use `git branch`, `git merge`, `git tag`, `gh pr create`, `az repos pr create`, or any other raw git/gh/az command for branch lifecycle operations. **ALL** branch creation, merging, tagging, PR creation, and version bumping MUST go through `bflow`.

**ONLY EXCEPTION:** The user explicitly asks you to bypass bflow.

Expand Down Expand Up @@ -78,7 +78,7 @@ PR bodies resolve from `.github/pr-templates/bflow-<key>.md`, most-specific firs
1. Branch-specific: `bflow-<type>.md` (e.g. `bflow-release-fix.md`)
2. Group: the fix family (`fix`, `release-fix`, `hotfix-fix`) shares `bflow-fix.md`; other types' group == their own name
3. `bflow-default.md`
4. Repo's git default (`.github/PULL_REQUEST_TEMPLATE.md` etc.), else empty body
4. Repo's git default (`.github/PULL_REQUEST_TEMPLATE.md` etc. on GitHub; `.azuredevops/pull_request_template.md` etc. on Azure DevOps), else empty body

Opt-in: with no `.github/pr-templates/`, behavior is unchanged.

Expand Down Expand Up @@ -143,6 +143,8 @@ Not available for `start release`.
## Prerequisites

bflow runs preflight checks automatically:
- `git` and `gh` must be installed
- `gh auth login` must be completed
- `git` must be installed
- The hosting provider is auto-detected from the origin remote URL (`dev.azure.com` / `*.visualstudio.com` → Azure DevOps, else GitHub); override with `git config bflow.hosting.provider github|devops`
- GitHub repos: `gh` installed + `gh auth login` completed
- Azure DevOps repos: `az` installed + `az extension add --name azure-devops` + authenticated (`az login`, or a PAT via `az devops login`)
- Uncommitted changes are auto-stashed and restored after the operation
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,18 @@ cargo install --path .
### Requirements

- [git](https://git-scm.com/)
- [gh](https://cli.github.com/) (GitHub CLI, authenticated via `gh auth login`)
- For GitHub repos: [gh](https://cli.github.com/) (GitHub CLI, authenticated via `gh auth login`)
- For Azure DevOps repos: [az](https://learn.microsoft.com/cli/azure/) (Azure CLI) with the `azure-devops` extension (`az extension add --name azure-devops`), authenticated via `az login` or a PAT via `az devops login`

### Hosting provider

bflow auto-detects the hosting provider from the `origin` remote URL: `dev.azure.com` and `*.visualstudio.com` remotes use Azure DevOps (org/project/repo are parsed from the URL), everything else uses GitHub. Only the CLI of the detected provider needs to be installed.

To override detection (e.g. a GitHub Enterprise domain), set:

```bash
git config bflow.hosting.provider github # or: devops (requires an Azure DevOps origin URL)
```

## Branch Model

Expand Down Expand Up @@ -219,7 +230,7 @@ When `bflow finish` opens a PR, it picks the body template by branch type. Place
1. **Branch-specific** — `bflow-<type>.md` (e.g. `bflow-release-fix.md`)
2. **Group** — the fix family (`fix`, `release-fix`, `hotfix-fix`) shares `bflow-fix.md`; every other type's group equals its own name
3. **Default** — `bflow-default.md`
4. **Git default** — the repo's own `.github/PULL_REQUEST_TEMPLATE.md` (and the other paths `gh` recognizes), else an empty body
4. **Git default** — the repo's own default template, else an empty body. On GitHub: `.github/PULL_REQUEST_TEMPLATE.md` and the other paths `gh` recognizes. On Azure DevOps: `.azuredevops/pull_request_template.md` and the other ADO default paths.

| File | Applies to |
|------|-----------|
Expand All @@ -230,7 +241,7 @@ When `bflow finish` opens a PR, it picks the body template by branch type. Place
| `bflow-chore.md` / `bflow-docs.md` / `bflow-refactor.md` | `chore/*` / `docs/*` / `refactor/*` |
| `bflow-default.md` | any PR with no more specific match |

The feature is opt-in: with no `.github/pr-templates/` directory, bflow falls back to the existing git default behavior unchanged.
The feature is opt-in: with no `.github/pr-templates/` directory, bflow falls back to the existing git default behavior unchanged. bflow templates live in `.github/pr-templates/` on every hosting provider, including Azure DevOps.

### Release-only commands

Expand Down Expand Up @@ -564,7 +575,9 @@ src/
│ └── branch.rs — Branch type detection and parsing
├── hosting/
│ ├── mod.rs — Hosting platform trait
│ └── github.rs — GitHub implementation via gh CLI
│ ├── detect.rs — Provider detection from the origin remote URL
│ ├── github.rs — GitHub implementation via gh CLI
│ └── devops.rs — Azure DevOps implementation via az CLI
├── flows/
│ ├── start.rs — Start work/release-fix/hotfix-fix
│ ├── finish_work.rs — PR creation for work branches
Expand All @@ -577,7 +590,7 @@ src/
└── worktree.rs — Worktree config, path resolution, and setup wizard
```

The `Git` and `HostingPlatform` traits enable future extensibility (e.g. GitLab, Bitbucket) and testability.
The `Git` and `HostingPlatform` traits enable multiple hosting providers (GitHub and Azure DevOps today, GitLab/Bitbucket-ready) and testability.

## License

Expand Down
8 changes: 8 additions & 0 deletions src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ pub trait Git {
fn git_dir(&self) -> Result<PathBuf>;
fn rev_parse(&self, refname: &str) -> Result<String>;

/// URL of the `origin` remote (`git remote get-url origin`). Errors when the
/// remote does not exist; callers decide how to handle a missing remote.
fn remote_url(&self) -> Result<String>;

// Worktree / config primitives
/// Read a git config value (`git config --get <key>`). Returns `None` when unset.
fn get_config(&self, key: &str) -> Result<Option<String>>;
Expand Down Expand Up @@ -237,6 +241,10 @@ impl Git for GitCli {
self.run(&["rev-parse", refname])
}

fn remote_url(&self) -> Result<String> {
self.run(&["remote", "get-url", "origin"])
}

fn get_config(&self, key: &str) -> Result<Option<String>> {
self.run_config(&["config", "--get", key])
}
Expand Down
228 changes: 228 additions & 0 deletions src/hosting/detect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
//! Hosting-provider detection.
//!
//! The provider is auto-detected from the `origin` remote URL. The git config
//! key `bflow.hosting.provider` (`github` | `devops`) overrides detection for
//! edge cases (e.g. GitHub Enterprise domains).

use crate::git::Git;
use super::Result;

const PROVIDER_KEY: &str = "bflow.hosting.provider";

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Provider {
GitHub,
AzureDevOps { org: String, project: String, repo: String },
}

/// Detect the hosting provider from the `bflow.hosting.provider` override and
/// the `origin` remote URL.
pub fn detect(git: &dyn Git) -> Result<Provider> {
let override_val = git.get_config(PROVIDER_KEY)?;
let remote = git.remote_url().ok();
resolve(remote.as_deref(), override_val.as_deref())
}

/// Pure resolution core (unit-tested without a git repo).
///
/// - Override `github` → GitHub unconditionally.
/// - Override `devops` → the remote must be an Azure DevOps URL (az needs the
/// org/project/repo coordinates) or an error is returned.
/// - No override → parse the remote; unrecognized hosts and missing remotes
/// fall back to GitHub (preserves pre-detection behavior).
fn resolve(remote_url: Option<&str>, override_val: Option<&str>) -> Result<Provider> {
match override_val.map(str::trim) {
Some("github") => Ok(Provider::GitHub),
Some("devops") => match remote_url.and_then(parse_remote) {
Some(p @ Provider::AzureDevOps { .. }) => Ok(p),
_ => Err(format!(
"{PROVIDER_KEY} is 'devops' but the Azure DevOps organization/project/repository could not be determined from the origin remote URL."
)),
},
Some(other) => Err(format!(
"Invalid {PROVIDER_KEY} value '{other}'. Valid values: github, devops."
)),
None => Ok(remote_url.and_then(parse_remote).unwrap_or(Provider::GitHub)),
}
}

/// Parse a remote URL into a provider. Returns `None` for unrecognized hosts.
fn parse_remote(url: &str) -> Option<Provider> {
let url = url.trim().trim_end_matches('/');
let url = url.strip_suffix(".git").unwrap_or(url);

// SSH scp-like syntax: git@host:path
if let Some(rest) = url.strip_prefix("git@") {
let (host, path) = rest.split_once(':')?;
return parse_host_path(host, path);
}

// URL syntax: scheme://[user@]host/path
let rest = url.split_once("://").map(|(_, r)| r)?;
let rest = rest.rsplit_once('@').map_or(rest, |(_, r)| r);
let (host, path) = rest.split_once('/')?;
parse_host_path(host, path)
}

fn parse_host_path(host: &str, path: &str) -> Option<Provider> {
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();

match host {
"github.com" => Some(Provider::GitHub),
// https://dev.azure.com/{org}/{project}/_git/{repo}
"dev.azure.com" => match segments.as_slice() {
[org, project, "_git", repo] => Some(azure(org, project, repo)),
_ => None,
},
// git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
"ssh.dev.azure.com" => match segments.as_slice() {
["v3", org, project, repo] => Some(azure(org, project, repo)),
_ => None,
},
// Legacy https://{org}.visualstudio.com/[DefaultCollection/]{project}/_git/{repo}
_ => {
let org = host.strip_suffix(".visualstudio.com")?;
match segments.as_slice() {
[project, "_git", repo] | ["DefaultCollection", project, "_git", repo] => {
Some(azure(org, project, repo))
}
_ => None,
}
}
}
}

fn azure(org: &str, project: &str, repo: &str) -> Provider {
Provider::AzureDevOps {
org: org.to_string(),
// https remotes percent-encode spaces in project names; az wants the
// display name. Spaces are the only realistic case in ADO names.
project: project.replace("%20", " "),
repo: repo.replace("%20", " "),
}
}

#[cfg(test)]
mod tests {
use super::*;

fn ado(org: &str, project: &str, repo: &str) -> Provider {
Provider::AzureDevOps {
org: org.to_string(),
project: project.to_string(),
repo: repo.to_string(),
}
}

#[test]
fn github_https() {
assert_eq!(parse_remote("https://github.com/acme/repo.git"), Some(Provider::GitHub));
assert_eq!(parse_remote("https://github.com/acme/repo"), Some(Provider::GitHub));
}

#[test]
fn github_ssh() {
assert_eq!(parse_remote("git@github.com:acme/repo.git"), Some(Provider::GitHub));
assert_eq!(parse_remote("ssh://git@github.com/acme/repo.git"), Some(Provider::GitHub));
}

#[test]
fn devops_https() {
assert_eq!(
parse_remote("https://dev.azure.com/beans/Shop/_git/backend"),
Some(ado("beans", "Shop", "backend")),
);
}

#[test]
fn devops_https_with_user() {
assert_eq!(
parse_remote("https://beans@dev.azure.com/beans/Shop/_git/backend"),
Some(ado("beans", "Shop", "backend")),
);
}

#[test]
fn devops_https_percent_encoded_project() {
assert_eq!(
parse_remote("https://dev.azure.com/beans/My%20Shop/_git/backend"),
Some(ado("beans", "My Shop", "backend")),
);
}

#[test]
fn devops_ssh_v3() {
assert_eq!(
parse_remote("git@ssh.dev.azure.com:v3/beans/Shop/backend"),
Some(ado("beans", "Shop", "backend")),
);
assert_eq!(
parse_remote("ssh://git@ssh.dev.azure.com/v3/beans/Shop/backend"),
Some(ado("beans", "Shop", "backend")),
);
}

#[test]
fn devops_legacy_visualstudio() {
assert_eq!(
parse_remote("https://beans.visualstudio.com/Shop/_git/backend"),
Some(ado("beans", "Shop", "backend")),
);
assert_eq!(
parse_remote("https://beans.visualstudio.com/DefaultCollection/Shop/_git/backend"),
Some(ado("beans", "Shop", "backend")),
);
}

#[test]
fn trailing_slash_and_git_suffix_stripped() {
assert_eq!(
parse_remote("https://dev.azure.com/beans/Shop/_git/backend.git/"),
Some(ado("beans", "Shop", "backend")),
);
}

#[test]
fn unrecognized_hosts_yield_none() {
assert_eq!(parse_remote("https://gitlab.com/acme/repo.git"), None);
assert_eq!(parse_remote("https://github.enterprise.corp/acme/repo"), None);
assert_eq!(parse_remote("not-a-url"), None);
assert_eq!(parse_remote("https://dev.azure.com/beans/backend"), None);
}

#[test]
fn no_override_unrecognized_or_missing_remote_defaults_to_github() {
assert_eq!(resolve(Some("https://gitlab.com/a/b"), None), Ok(Provider::GitHub));
assert_eq!(resolve(None, None), Ok(Provider::GitHub));
}

#[test]
fn no_override_ado_remote_detected() {
assert_eq!(
resolve(Some("https://dev.azure.com/beans/Shop/_git/backend"), None),
Ok(ado("beans", "Shop", "backend")),
);
}

#[test]
fn override_github_wins_over_ado_remote() {
assert_eq!(
resolve(Some("https://dev.azure.com/beans/Shop/_git/backend"), Some("github")),
Ok(Provider::GitHub),
);
}

#[test]
fn override_devops_requires_parsable_ado_remote() {
let err = resolve(Some("https://github.com/acme/repo"), Some("devops")).unwrap_err();
assert!(err.contains("could not be determined"), "{err}");
let err = resolve(None, Some("devops")).unwrap_err();
assert!(err.contains("could not be determined"), "{err}");
}

#[test]
fn override_invalid_value_errors() {
let err = resolve(Some("https://github.com/acme/repo"), Some("gitlab")).unwrap_err();
assert!(err.contains("Valid values: github, devops"), "{err}");
}
}
Loading
Loading