use std::path::PathBuf;
mod diff;
mod error;
mod parse;
pub mod recover;
pub mod remote_ref;
mod repository;
mod url;
#[cfg(test)]
mod test;
use crate::sync::Semaphore;
use std::sync::LazyLock;
static HEAVY_OPS_SEMAPHORE: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(4));
pub const NULL_OID: &str = "0000000000000000000000000000000000000000";
pub(crate) use diff::DiffStats;
pub use diff::{LineDiff, parse_numstat_line};
pub use error::{
FailedCommand,
GitError,
HookErrorWithHint,
RefContext,
RefType,
SwitchSuggestionCtx,
WorktrunkError,
add_hook_skip_hint,
exit_code,
};
pub use parse::{parse_porcelain_z, parse_untracked_files};
pub use recover::{current_or_recover, cwd_removed_hint};
pub use repository::{Branch, Repository, ResolvedWorktree, WorkingTree, set_base_path};
pub use url::GitRemoteUrl;
pub use url::parse_owner_repo;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, strum::IntoStaticStr)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum IntegrationReason {
SameCommit,
Ancestor,
NoAddedChanges,
TreesMatch,
MergeAddsNothing,
PatchIdMatch,
}
impl IntegrationReason {
pub fn description(&self) -> &'static str {
match self {
Self::SameCommit => "same commit as",
Self::Ancestor => "ancestor of",
Self::NoAddedChanges => "no added changes on",
Self::TreesMatch => "tree matches",
Self::MergeAddsNothing => "all changes in",
Self::PatchIdMatch => "all changes in",
}
}
pub fn symbol(&self) -> &'static str {
match self {
Self::SameCommit => "_",
_ => "⊂",
}
}
}
#[derive(Debug, Default)]
pub struct IntegrationSignals {
pub is_same_commit: Option<bool>,
pub is_ancestor: Option<bool>,
pub has_added_changes: Option<bool>,
pub trees_match: Option<bool>,
pub would_merge_add: Option<bool>,
pub is_patch_id_match: Option<bool>,
}
pub fn check_integration(signals: &IntegrationSignals) -> Option<IntegrationReason> {
if signals.is_same_commit == Some(true) {
return Some(IntegrationReason::SameCommit);
}
if signals.is_ancestor == Some(true) {
return Some(IntegrationReason::Ancestor);
}
if signals.has_added_changes == Some(false) {
return Some(IntegrationReason::NoAddedChanges);
}
if signals.trees_match == Some(true) {
return Some(IntegrationReason::TreesMatch);
}
if signals.would_merge_add == Some(false) {
return Some(IntegrationReason::MergeAddsNothing);
}
if signals.is_patch_id_match == Some(true) {
return Some(IntegrationReason::PatchIdMatch);
}
None
}
#[allow(clippy::field_reassign_with_default)] pub fn compute_integration_lazy(
repo: &Repository,
branch: &str,
target: &str,
) -> anyhow::Result<IntegrationSignals> {
let mut signals = IntegrationSignals::default();
signals.is_same_commit = Some(repo.same_commit(branch, target)?);
if signals.is_same_commit == Some(true) {
return Ok(signals);
}
signals.is_ancestor = Some(repo.is_ancestor(branch, target)?);
if signals.is_ancestor == Some(true) {
return Ok(signals);
}
signals.has_added_changes = Some(repo.has_added_changes(branch, target)?);
if signals.has_added_changes == Some(false) {
return Ok(signals);
}
signals.trees_match = Some(repo.trees_match(branch, target)?);
if signals.trees_match == Some(true) {
return Ok(signals);
}
let probe = repo.merge_integration_probe(branch, target)?;
signals.would_merge_add = Some(probe.would_merge_add);
if !probe.would_merge_add {
return Ok(signals);
}
signals.is_patch_id_match = Some(probe.is_patch_id_match);
Ok(signals)
}
#[derive(Debug, Clone, PartialEq)]
pub enum BranchCategory {
Worktree,
Local,
Remote(Vec<String>),
}
#[derive(Debug, Clone)]
pub struct CompletionBranch {
pub name: String,
pub timestamp: i64,
pub category: BranchCategory,
}
pub(crate) use parse::DefaultBranchName;
use crate::shell_exec::Cmd;
pub fn branch_tracks_ref(
repo_root: &std::path::Path,
branch: &str,
expected_ref: &str,
expected_remote: Option<&str>,
) -> Option<bool> {
let config_key = format!("branch.{}.merge", branch);
let output = Cmd::new("git")
.args(["config", "--get", &config_key])
.current_dir(repo_root)
.run()
.ok()?;
if !output.status.success() {
let branch_exists = Cmd::new("git")
.args([
"show-ref",
"--verify",
"--quiet",
&format!("refs/heads/{}", branch),
])
.current_dir(repo_root)
.run()
.map(|o| o.status.success())
.unwrap_or(false);
return if branch_exists { Some(false) } else { None };
}
let merge_ref = String::from_utf8_lossy(&output.stdout).trim().to_string();
if merge_ref != expected_ref {
return Some(false);
}
let Some(expected_remote) = expected_remote else {
return Some(true);
};
let remote_key = format!("branch.{}.remote", branch);
let remote_output = Cmd::new("git")
.args(["config", "--get", &remote_key])
.current_dir(repo_root)
.run()
.ok()?;
if !remote_output.status.success() {
return Some(false);
}
let remote = String::from_utf8_lossy(&remote_output.stdout)
.trim()
.to_string();
Some(remote == expected_remote)
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
clap::ValueEnum,
serde::Serialize,
serde::Deserialize,
strum::Display,
strum::EnumString,
strum::EnumIter,
)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum HookType {
PreSwitch,
PostSwitch,
PreStart,
PostStart,
PreCommit,
PostCommit,
PreMerge,
PostMerge,
PreRemove,
PostRemove,
}
#[derive(Debug, Clone)]
pub struct BranchRef {
pub branch: Option<String>,
pub commit_sha: String,
pub worktree_path: Option<PathBuf>,
pub is_remote: bool,
}
impl BranchRef {
pub fn local_branch(branch: &str, commit_sha: &str) -> Self {
Self {
branch: Some(branch.to_string()),
commit_sha: commit_sha.to_string(),
worktree_path: None,
is_remote: false,
}
}
pub fn remote_branch(branch: &str, commit_sha: &str) -> Self {
Self {
branch: Some(branch.to_string()),
commit_sha: commit_sha.to_string(),
worktree_path: None,
is_remote: true,
}
}
pub fn working_tree<'a>(&self, repo: &'a Repository) -> Option<WorkingTree<'a>> {
self.worktree_path
.as_ref()
.map(|p| repo.worktree_at(p.clone()))
}
pub fn has_worktree(&self) -> bool {
self.worktree_path.is_some()
}
}
impl From<&WorktreeInfo> for BranchRef {
fn from(wt: &WorktreeInfo) -> Self {
Self {
branch: wt.branch.clone(),
commit_sha: wt.head.clone(),
worktree_path: Some(wt.path.clone()),
is_remote: false, }
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct WorktreeInfo {
pub path: PathBuf,
pub head: String,
pub branch: Option<String>,
pub bare: bool,
pub detached: bool,
pub locked: Option<String>,
pub prunable: Option<String>,
}
pub fn path_dir_name(path: &std::path::Path) -> &str {
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("(unknown)")
}
impl WorktreeInfo {
pub fn is_prunable(&self) -> bool {
self.prunable.is_some()
}
pub fn has_commits(&self) -> bool {
self.head != NULL_OID
}
pub fn dir_name(&self) -> &str {
path_dir_name(&self.path)
}
}
fn read_rebase_branch(worktree_path: &PathBuf) -> Option<String> {
let repo = Repository::current().ok()?;
let git_dir = repo.worktree_at(worktree_path).git_dir().ok()?;
for rebase_dir in ["rebase-merge", "rebase-apply"] {
let head_name_path = git_dir.join(rebase_dir).join("head-name");
if let Ok(content) = std::fs::read_to_string(head_name_path) {
let branch_ref = content.trim();
let branch = branch_ref
.strip_prefix("refs/heads/")
.unwrap_or(branch_ref)
.to_string();
return Some(branch);
}
}
None
}
pub(crate) fn finalize_worktree(mut wt: WorktreeInfo) -> WorktreeInfo {
if wt.detached
&& wt.branch.is_none()
&& let Some(branch) = read_rebase_branch(&wt.path)
{
wt.branch = Some(branch);
}
wt
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_check_integration() {
let cases = [
(
(
Some(true),
Some(false),
Some(true),
Some(false),
Some(true),
None,
),
Some(IntegrationReason::SameCommit),
),
(
(
Some(false),
Some(true),
Some(true),
Some(false),
Some(true),
None,
),
Some(IntegrationReason::Ancestor),
),
(
(
Some(false),
Some(false),
Some(false),
Some(false),
Some(true),
None,
),
Some(IntegrationReason::NoAddedChanges),
),
(
(
Some(false),
Some(false),
Some(true),
Some(true),
Some(true),
None,
),
Some(IntegrationReason::TreesMatch),
),
(
(
Some(false),
Some(false),
Some(true),
Some(false),
Some(false),
None,
),
Some(IntegrationReason::MergeAddsNothing),
),
(
(
Some(false),
Some(false),
Some(true),
Some(false),
Some(true),
Some(true),
),
Some(IntegrationReason::PatchIdMatch),
),
(
(
Some(false),
Some(false),
Some(true),
Some(false),
Some(true),
Some(false),
),
None,
),
(
(
Some(true),
Some(true),
Some(false),
Some(true),
Some(false),
None,
),
Some(IntegrationReason::SameCommit),
), ((None, None, None, None, None, None), None),
(
(None, Some(true), Some(false), Some(true), Some(false), None),
Some(IntegrationReason::Ancestor),
),
];
for ((same, ancestor, added, trees, merge, patch_id), expected) in cases {
let signals = IntegrationSignals {
is_same_commit: same,
is_ancestor: ancestor,
has_added_changes: added,
trees_match: trees,
would_merge_add: merge,
is_patch_id_match: patch_id,
};
assert_eq!(
check_integration(&signals),
expected,
"case: {same:?},{ancestor:?},{added:?},{trees:?},{merge:?},{patch_id:?}"
);
}
}
#[test]
fn test_integration_reason_description() {
assert_eq!(
IntegrationReason::SameCommit.description(),
"same commit as"
);
assert_eq!(IntegrationReason::Ancestor.description(), "ancestor of");
assert_eq!(
IntegrationReason::NoAddedChanges.description(),
"no added changes on"
);
assert_eq!(IntegrationReason::TreesMatch.description(), "tree matches");
assert_eq!(
IntegrationReason::MergeAddsNothing.description(),
"all changes in"
);
assert_eq!(
IntegrationReason::PatchIdMatch.description(),
"all changes in"
);
}
#[test]
fn test_path_dir_name() {
assert_eq!(
path_dir_name(&PathBuf::from("/home/user/repo.feature")),
"repo.feature"
);
assert_eq!(path_dir_name(&PathBuf::from("/")), "(unknown)");
assert!(!path_dir_name(&PathBuf::from("/home/user/repo/")).is_empty());
let wt = WorktreeInfo {
path: PathBuf::from("/repos/myrepo.feature"),
head: "abc123".into(),
branch: Some("feature".into()),
bare: false,
detached: false,
locked: None,
prunable: None,
};
assert_eq!(wt.dir_name(), "myrepo.feature");
}
#[test]
fn test_hook_type_display() {
use strum::IntoEnumIterator;
for hook in HookType::iter() {
let display = format!("{hook}");
assert!(
display.chars().all(|c| c.is_lowercase() || c == '-'),
"Hook {hook:?} should be kebab-case, got: {display}"
);
}
}
#[test]
fn test_branch_ref_from_worktree_info() {
let wt = WorktreeInfo {
path: PathBuf::from("/repo.feature"),
head: "abc123".into(),
branch: Some("feature".into()),
bare: false,
detached: false,
locked: None,
prunable: None,
};
let branch_ref = BranchRef::from(&wt);
assert_eq!(branch_ref.branch, Some("feature".to_string()));
assert_eq!(branch_ref.commit_sha, "abc123");
assert_eq!(
branch_ref.worktree_path,
Some(PathBuf::from("/repo.feature"))
);
assert!(branch_ref.has_worktree());
assert!(!branch_ref.is_remote); }
#[test]
fn test_branch_ref_local_branch() {
let branch_ref = BranchRef::local_branch("feature", "abc123");
assert_eq!(branch_ref.branch, Some("feature".to_string()));
assert_eq!(branch_ref.commit_sha, "abc123");
assert_eq!(branch_ref.worktree_path, None);
assert!(!branch_ref.has_worktree());
assert!(!branch_ref.is_remote);
}
#[test]
fn test_branch_ref_remote_branch() {
let branch_ref = BranchRef::remote_branch("origin/feature", "abc123");
assert_eq!(branch_ref.branch, Some("origin/feature".to_string()));
assert_eq!(branch_ref.commit_sha, "abc123");
assert_eq!(branch_ref.worktree_path, None);
assert!(!branch_ref.has_worktree());
assert!(branch_ref.is_remote);
}
#[test]
fn test_branch_tracks_ref_matching() {
let test = crate::testing::TestRepo::with_initial_commit();
let repo = test.path();
crate::shell_exec::Cmd::new("git")
.args(["branch", "pr-branch"])
.current_dir(repo)
.run()
.unwrap();
crate::shell_exec::Cmd::new("git")
.args(["config", "branch.pr-branch.merge", "refs/pull/101/head"])
.current_dir(repo)
.run()
.unwrap();
crate::shell_exec::Cmd::new("git")
.args(["config", "branch.pr-branch.remote", "origin"])
.current_dir(repo)
.run()
.unwrap();
assert_eq!(
branch_tracks_ref(repo, "pr-branch", "refs/pull/101/head", None),
Some(true),
);
assert_eq!(
branch_tracks_ref(repo, "pr-branch", "refs/pull/101/head", Some("origin")),
Some(true),
);
}
#[test]
fn test_branch_tracks_ref_different_ref() {
let test = crate::testing::TestRepo::with_initial_commit();
let repo = test.path();
crate::shell_exec::Cmd::new("git")
.args(["branch", "pr-branch"])
.current_dir(repo)
.run()
.unwrap();
crate::shell_exec::Cmd::new("git")
.args(["config", "branch.pr-branch.merge", "refs/pull/101/head"])
.current_dir(repo)
.run()
.unwrap();
assert_eq!(
branch_tracks_ref(repo, "pr-branch", "refs/pull/999/head", None),
Some(false),
);
}
#[test]
fn test_branch_tracks_ref_wrong_remote() {
let test = crate::testing::TestRepo::with_initial_commit();
let repo = test.path();
crate::shell_exec::Cmd::new("git")
.args(["branch", "pr-branch"])
.current_dir(repo)
.run()
.unwrap();
crate::shell_exec::Cmd::new("git")
.args(["config", "branch.pr-branch.merge", "refs/pull/101/head"])
.current_dir(repo)
.run()
.unwrap();
crate::shell_exec::Cmd::new("git")
.args(["config", "branch.pr-branch.remote", "fork"])
.current_dir(repo)
.run()
.unwrap();
assert_eq!(
branch_tracks_ref(repo, "pr-branch", "refs/pull/101/head", Some("origin")),
Some(false),
);
}
#[test]
fn test_branch_tracks_ref_no_tracking_config() {
let test = crate::testing::TestRepo::with_initial_commit();
let repo = test.path();
crate::shell_exec::Cmd::new("git")
.args(["branch", "local-only"])
.current_dir(repo)
.run()
.unwrap();
assert_eq!(
branch_tracks_ref(repo, "local-only", "refs/pull/1/head", None),
Some(false),
);
}
#[test]
fn test_branch_tracks_ref_nonexistent_branch() {
let test = crate::testing::TestRepo::with_initial_commit();
let repo = test.path();
assert_eq!(
branch_tracks_ref(repo, "no-such-branch", "refs/pull/1/head", None),
None,
);
}
#[test]
fn test_branch_tracks_ref_invalid_repo_path() {
let bad_path = std::path::Path::new("/nonexistent/repo/path");
assert_eq!(
branch_tracks_ref(bad_path, "main", "refs/pull/1/head", None),
None,
);
}
#[test]
fn test_branch_tracks_ref_mr_ref() {
let test = crate::testing::TestRepo::with_initial_commit();
let repo = test.path();
crate::shell_exec::Cmd::new("git")
.args(["branch", "mr-branch"])
.current_dir(repo)
.run()
.unwrap();
crate::shell_exec::Cmd::new("git")
.args([
"config",
"branch.mr-branch.merge",
"refs/merge-requests/42/head",
])
.current_dir(repo)
.run()
.unwrap();
crate::shell_exec::Cmd::new("git")
.args(["config", "branch.mr-branch.remote", "origin"])
.current_dir(repo)
.run()
.unwrap();
assert_eq!(
branch_tracks_ref(
repo,
"mr-branch",
"refs/merge-requests/42/head",
Some("origin"),
),
Some(true),
);
assert_eq!(
branch_tracks_ref(repo, "mr-branch", "refs/pull/42/head", Some("origin")),
Some(false),
);
}
#[test]
fn test_branch_ref_detached_head() {
let wt = WorktreeInfo {
path: PathBuf::from("/repo.detached"),
head: "def456".into(),
branch: None, bare: false,
detached: true,
locked: None,
prunable: None,
};
let branch_ref = BranchRef::from(&wt);
assert_eq!(branch_ref.branch, None);
assert_eq!(branch_ref.commit_sha, "def456");
assert!(branch_ref.has_worktree());
}
}