pub mod mock;
pub mod pr;
pub mod shell;
use std::path::Path;
use anyhow::Result;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::plan::PhaseId;
pub use mock::{MockGit, MockOp};
pub use pr::{grind_pr_title, open_grind_pr, pr_body, pr_title, PrSummary};
pub use shell::ShellGit;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CommitId(String);
impl CommitId {
pub fn new(hash: impl Into<String>) -> Self {
Self(hash.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for CommitId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiffStat {
pub files_changed: u64,
pub insertions: u64,
pub deletions: u64,
}
#[derive(Debug, Error)]
pub enum GitError {
#[error("git {operation} failed (exit {exit:?}): {stderr}")]
Command {
operation: String,
exit: Option<i32>,
stderr: String,
},
#[error("git {operation}: unexpected output: {output}")]
UnexpectedOutput {
operation: String,
output: String,
},
}
#[async_trait]
pub trait Git: Send + Sync {
async fn is_clean(&self) -> Result<bool>;
async fn current_branch(&self) -> Result<String>;
async fn create_branch(&self, name: &str) -> Result<()>;
async fn checkout(&self, name: &str) -> Result<()>;
async fn stage_changes(&self, exclude: &[&Path]) -> Result<()>;
async fn has_staged_changes(&self) -> Result<bool>;
async fn commit(&self, message: &str) -> Result<CommitId>;
async fn diff_stat(&self, from: &str, to: &str) -> Result<DiffStat>;
async fn staged_diff(&self) -> Result<String>;
async fn stash_push(&self, message: &str, exclude: &[&Path]) -> Result<bool>;
async fn open_pr(&self, title: &str, body: &str) -> Result<String>;
async fn add_worktree(&self, path: &Path, branch: &str, base_branch: &str) -> Result<()>;
async fn remove_worktree(&self, path: &Path) -> Result<()>;
async fn delete_branch(&self, branch: &str) -> Result<()>;
async fn merge_ff_only(&self, source_branch: &str) -> Result<()>;
}
pub fn branch_name(prefix: &str, at: DateTime<Utc>) -> String {
format!("{}{}", prefix, at.format("%Y%m%dT%H%M%SZ"))
}
pub fn commit_message(phase_id: &PhaseId, title: &str) -> String {
format!("[pitboss] phase {}: {}", phase_id, title)
}
pub fn commit_message_sweep(after: &PhaseId, resolved: usize) -> String {
if resolved == 0 {
format!("[pitboss] sweep after phase {}: no items resolved", after)
} else {
format!(
"[pitboss] sweep after phase {}: {} deferred items resolved",
after, resolved
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pid(s: &str) -> PhaseId {
PhaseId::parse(s).unwrap()
}
#[test]
fn branch_name_formats_timestamp_compactly() {
let at = DateTime::parse_from_rfc3339("2026-04-29T14:30:22Z")
.unwrap()
.with_timezone(&Utc);
assert_eq!(
branch_name("pitboss/play/", at),
"pitboss/play/20260429T143022Z"
);
assert_eq!(branch_name("", at), "20260429T143022Z");
}
#[test]
fn commit_message_uses_canonical_format() {
assert_eq!(
commit_message(&pid("02"), "Domain types"),
"[pitboss] phase 02: Domain types"
);
assert_eq!(
commit_message(&pid("10b"), "Followup"),
"[pitboss] phase 10b: Followup"
);
}
#[test]
fn commit_message_sweep_uses_canonical_format() {
assert_eq!(
commit_message_sweep(&pid("02"), 4),
"[pitboss] sweep after phase 02: 4 deferred items resolved"
);
assert_eq!(
commit_message_sweep(&pid("10b"), 0),
"[pitboss] sweep after phase 10b: no items resolved"
);
}
#[test]
fn commit_id_round_trips_through_display() {
let id = CommitId::new("abc123");
assert_eq!(id.as_str(), "abc123");
assert_eq!(format!("{}", id), "abc123");
}
}