use std::collections::HashMap;
use std::process::Command;
use color_eyre::{eyre::eyre, Result};
use super::Forge;
use crate::core::stack::{PatchEntry, PatchStatus};
use crate::git::ops::Repo;
pub struct Phabricator;
impl Forge for Phabricator {
fn name(&self) -> &str { "Phabricator" }
fn needs_description_editor(&self) -> bool { false }
fn get_trailers(&self, body: &str) -> Vec<String> {
body.lines()
.filter(|l| l.trim().starts_with("Differential Revision:"))
.map(|l| l.trim().to_string())
.collect()
}
fn submit(
&self, repo: &Repo, hash: &str, _subject: &str,
_base: &str, _body: &str,
) -> Result<String> {
let short = &hash[..7.min(hash.len())];
match repo.rebase_edit_commit(short) {
Ok(false) => {} Ok(true) => return Err(eyre!("Commit {} not found in stack", short)),
Err(e) => return Err(eyre!("Failed to start rebase: {}", e)),
}
let status = Command::new("arc")
.current_dir(&repo.workdir)
.args(["diff", "HEAD^"])
.status();
let msg = repo.git_pub(&["log", "-1", "--format=%B"])
.unwrap_or_default();
let revision_id = parse_revision_id(&msg);
let rebase_ok = match repo.rebase_continue() {
Ok(true) => true,
Ok(false) => false, Err(_) => false,
};
let arc_ran = status.is_ok();
let exit_ok = status.map(|s| s.success()).unwrap_or(false);
if revision_id.is_some() || exit_ok {
let id_str = revision_id
.map(|id| format!("D{}", id))
.unwrap_or_else(|| "unknown".to_string());
if rebase_ok {
Ok(format!("Revision created: {}", id_str))
} else {
Ok(format!("Revision created: {} (rebase has conflicts — resolve and run `git rebase --continue`)", id_str))
}
} else if !arc_ran {
let _ = repo.rebase_abort();
Err(eyre!("arc not found — install arcanist"))
} else {
let _ = repo.rebase_abort();
Err(eyre!("arc diff failed"))
}
}
fn update(
&self, repo: &Repo, hash: &str, _subject: &str, _base: &str,
) -> Result<String> {
let short = &hash[..7.min(hash.len())];
let msg = repo.git_pub(&["log", "-1", "--format=%B", hash])
.unwrap_or_default();
let revision_id = parse_revision_id(&msg);
match repo.rebase_edit_commit(short) {
Ok(false) => {}
Ok(true) => return Err(eyre!("Commit {} not found in stack", short)),
Err(e) => return Err(eyre!("Failed to start rebase: {}", e)),
}
let status = match &revision_id {
Some(id) => {
Command::new("arc")
.current_dir(&repo.workdir)
.args(["diff", "HEAD^", "--update", &format!("D{}", id)])
.status()
}
None => {
Command::new("arc")
.current_dir(&repo.workdir)
.args(["diff", "HEAD^"])
.status()
}
};
let msg = repo.git_pub(&["log", "-1", "--format=%B"])
.unwrap_or_default();
let revision_id = parse_revision_id(&msg).or(revision_id);
let rebase_ok = match repo.rebase_continue() {
Ok(true) => true,
Ok(false) => false,
Err(_) => false,
};
let arc_ran = status.is_ok();
let exit_ok = status.map(|s| s.success()).unwrap_or(false);
if revision_id.is_some() || exit_ok {
let id_str = revision_id
.map(|id| format!("D{}", id))
.unwrap_or_else(|| "unknown".to_string());
if rebase_ok {
Ok(format!("Revision updated: {}", id_str))
} else {
Ok(format!("Revision updated: {} (rebase has conflicts — resolve and run `git rebase --continue`)", id_str))
}
} else if !arc_ran {
let _ = repo.rebase_abort();
Err(eyre!("arc not found — install arcanist"))
} else {
let _ = repo.rebase_abort();
Err(eyre!("arc diff failed"))
}
}
fn list_open(&self, _repo: &Repo) -> (HashMap<String, u32>, bool) {
(HashMap::new(), false)
}
fn edit_base(&self, _repo: &Repo, _branch: &str, _base: &str) -> bool {
true
}
fn mark_submitted(&self, repo: &Repo, patches: &mut [PatchEntry]) {
for patch in patches.iter_mut() {
let full = repo.git_pub(&["log", "-1", "--format=%B", &patch.hash])
.unwrap_or_default();
if let Some((id, url)) = parse_revision_id_and_url(&full) {
patch.status = PatchStatus::Submitted;
patch.pr_number = Some(id);
patch.pr_url = Some(url);
}
}
}
fn sync(
&self, repo: &Repo, patches: &[PatchEntry],
on_progress: &dyn Fn(&str),
) -> Result<Vec<String>> {
let branch = repo.get_current_branch()?;
let mut updates = Vec::new();
for patch in patches {
if patch.status != PatchStatus::Submitted { continue; }
let id = match patch.pr_number {
Some(id) => id,
None => continue,
};
on_progress(&format!("Updating D{}: {} ...", id, &patch.subject));
if repo.git_pub(&["checkout", "--quiet", &patch.hash]).is_err() {
updates.push(format!("⚠ D{} checkout failed, skipping", id));
continue;
}
let status = Command::new("arc")
.current_dir(&repo.workdir)
.args(["diff", "HEAD^", "--update", &format!("D{}", id),
"--message", "Updated diff"])
.stdin(std::process::Stdio::null())
.status();
match status {
Ok(s) if s.success() => {
updates.push(format!("✓ D{} updated", id));
}
_ => {
updates.push(format!("⚠ D{} update failed", id));
}
}
}
let _ = repo.git_pub(&["checkout", "--quiet", &branch]);
Ok(updates)
}
}
fn parse_revision_id(message: &str) -> Option<u32> {
parse_revision_id_and_url(message).map(|(id, _)| id)
}
fn parse_revision_id_and_url(message: &str) -> Option<(u32, String)> {
for line in message.lines() {
let line = line.trim();
if line.starts_with("Differential Revision:") {
let url_part = line.trim_start_matches("Differential Revision:").trim();
if let Some(d_pos) = line.rfind('D') {
let num_str: String = line[d_pos + 1..]
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if let Ok(id) = num_str.parse::<u32>() {
return Some((id, url_part.to_string()));
}
}
}
}
None
}
#[allow(dead_code)]
fn parse_revision_from_arc_output(output: &str) -> Option<u32> {
for line in output.lines() {
let line = line.trim();
if line.contains("Revision URI:") || line.contains("revision/") {
if let Some(d_pos) = line.rfind("/D") {
let num_str: String = line[d_pos + 2..]
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if let Ok(id) = num_str.parse::<u32>() {
return Some(id);
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_revision_url() {
let msg = "Some commit\n\nDifferential Revision: https://phab.example.com/D1234";
assert_eq!(parse_revision_id(msg), Some(1234));
let (id, url) = parse_revision_id_and_url(msg).unwrap();
assert_eq!(id, 1234);
assert_eq!(url, "https://phab.example.com/D1234");
}
#[test]
fn parse_revision_bare() {
assert_eq!(parse_revision_id("Differential Revision: D5678"), Some(5678));
let (id, url) = parse_revision_id_and_url("Differential Revision: D5678").unwrap();
assert_eq!(id, 5678);
assert_eq!(url, "D5678");
}
#[test]
fn parse_revision_multiline() {
let msg = "fix bug\n\nDifferential Revision: https://phab.co/D42\nSome other line";
assert_eq!(parse_revision_id(msg), Some(42));
}
#[test]
fn parse_revision_not_present() {
assert_eq!(parse_revision_id("just a commit message"), None);
assert_eq!(parse_revision_id(""), None);
}
#[test]
fn parse_revision_wrong_prefix() {
assert_eq!(parse_revision_id("Reviewed-by: D9999"), None);
}
#[test]
fn parse_arc_output_revision_uri() {
let output = "Updated an existing Differential revision:\n Revision URI: https://p.daedalean.ai/D32750\n\nIncluded changes:\n M file.rs";
assert_eq!(parse_revision_from_arc_output(output), Some(32750));
}
#[test]
fn parse_arc_output_created() {
let output = "Created a new Differential revision:\n Revision URI: https://phab.example.com/D999";
assert_eq!(parse_revision_from_arc_output(output), Some(999));
}
#[test]
fn parse_arc_output_no_revision() {
assert_eq!(parse_revision_from_arc_output("Linting...\n OKAY"), None);
assert_eq!(parse_revision_from_arc_output(""), None);
}
}