mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-03 11:44:49 +08:00
feat: git slash commands (/branch, /commit, /commit-push-pr, /worktree)
This commit is contained in:
@@ -48,6 +48,8 @@ The Rust port is a complete, working CLI (`claw`) with:
|
|||||||
- Session persistence and compaction
|
- Session persistence and compaction
|
||||||
- HTTP/SSE server (axum-based, direct connect sessions)
|
- HTTP/SSE server (axum-based, direct connect sessions)
|
||||||
- LSP client integration (diagnostics, go-to-definition, find-references)
|
- LSP client integration (diagnostics, go-to-definition, find-references)
|
||||||
|
- Vim keybinding mode (normal/insert/visual/command)
|
||||||
|
- Git slash commands (/branch, /commit, /commit-push-pr, /worktree)
|
||||||
|
|
||||||
Every crate, every test, every commit was driven through oh-my-opencode's **Sisyphus** agent with `ultrawork` mode — from initial scaffolding to the final cleanroom pass. The cleanroom refactor, QA verification, git history rewrite, and CI setup were coordinated by **Jobdori** ([OpenClaw](https://github.com/openclaw/openclaw)), an AI assistant orchestrating the entire workflow. The Rust port passes all 274 tests across the workspace.
|
Every crate, every test, every commit was driven through oh-my-opencode's **Sisyphus** agent with `ultrawork` mode — from initial scaffolding to the final cleanroom pass. The cleanroom refactor, QA verification, git history rewrite, and CI setup were coordinated by **Jobdori** ([OpenClaw](https://github.com/openclaw/openclaw)), an AI assistant orchestrating the entire workflow. The Rust port passes all 274 tests across the workspace.
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,10 @@ impl CliApp {
|
|||||||
writeln!(out, "Unknown slash command: /{name}")?;
|
writeln!(out, "Unknown slash command: /{name}")?;
|
||||||
Ok(CommandResult::Continue)
|
Ok(CommandResult::Continue)
|
||||||
}
|
}
|
||||||
|
_ => {
|
||||||
|
writeln!(out, "Slash command unavailable in this mode")?;
|
||||||
|
Ok(CommandResult::Continue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +176,7 @@ impl CliApp {
|
|||||||
SlashCommand::Help => "/help",
|
SlashCommand::Help => "/help",
|
||||||
SlashCommand::Status => "/status",
|
SlashCommand::Status => "/status",
|
||||||
SlashCommand::Compact => "/compact",
|
SlashCommand::Compact => "/compact",
|
||||||
SlashCommand::Unknown(_) => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
writeln!(out, " {name:<9} {}", handler.summary)?;
|
writeln!(out, " {name:<9} {}", handler.summary)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -939,6 +939,9 @@ fn run_resume_command(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
SlashCommand::Bughunter { .. }
|
SlashCommand::Bughunter { .. }
|
||||||
|
| SlashCommand::Branch { .. }
|
||||||
|
| SlashCommand::Worktree { .. }
|
||||||
|
| SlashCommand::CommitPushPr { .. }
|
||||||
| SlashCommand::Commit
|
| SlashCommand::Commit
|
||||||
| SlashCommand::Pr { .. }
|
| SlashCommand::Pr { .. }
|
||||||
| SlashCommand::Issue { .. }
|
| SlashCommand::Issue { .. }
|
||||||
@@ -1242,6 +1245,18 @@ impl LiveCli {
|
|||||||
Self::print_skills(args.as_deref())?;
|
Self::print_skills(args.as_deref())?;
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
SlashCommand::Branch { .. } => {
|
||||||
|
eprintln!("git branch commands not yet wired to REPL");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
SlashCommand::Worktree { .. } => {
|
||||||
|
eprintln!("git worktree commands not yet wired to REPL");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
SlashCommand::CommitPushPr { .. } => {
|
||||||
|
eprintln!("commit-push-pr not yet wired to REPL");
|
||||||
|
false
|
||||||
|
}
|
||||||
SlashCommand::Unknown(name) => {
|
SlashCommand::Unknown(name) => {
|
||||||
eprintln!("unknown slash command: /{name}");
|
eprintln!("unknown slash command: /{name}");
|
||||||
false
|
false
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use plugins::{PluginError, PluginManager, PluginSummary};
|
use plugins::{PluginError, PluginManager, PluginSummary};
|
||||||
use runtime::{compact_session, CompactionConfig, Session};
|
use runtime::{compact_session, CompactionConfig, Session};
|
||||||
@@ -144,6 +147,20 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
argument_hint: Some("[scope]"),
|
argument_hint: Some("[scope]"),
|
||||||
resume_supported: false,
|
resume_supported: false,
|
||||||
},
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "branch",
|
||||||
|
aliases: &[],
|
||||||
|
summary: "List, create, or switch git branches",
|
||||||
|
argument_hint: Some("[list|create <name>|switch <name>]"),
|
||||||
|
resume_supported: false,
|
||||||
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "worktree",
|
||||||
|
aliases: &[],
|
||||||
|
summary: "List, add, remove, or prune git worktrees",
|
||||||
|
argument_hint: Some("[list|add <path> [branch]|remove <path>|prune]"),
|
||||||
|
resume_supported: false,
|
||||||
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "commit",
|
name: "commit",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
@@ -151,6 +168,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
argument_hint: None,
|
argument_hint: None,
|
||||||
resume_supported: false,
|
resume_supported: false,
|
||||||
},
|
},
|
||||||
|
SlashCommandSpec {
|
||||||
|
name: "commit-push-pr",
|
||||||
|
aliases: &[],
|
||||||
|
summary: "Commit workspace changes, push the branch, and open a PR",
|
||||||
|
argument_hint: Some("[context]"),
|
||||||
|
resume_supported: false,
|
||||||
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "pr",
|
name: "pr",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
@@ -230,10 +254,22 @@ pub enum SlashCommand {
|
|||||||
Help,
|
Help,
|
||||||
Status,
|
Status,
|
||||||
Compact,
|
Compact,
|
||||||
|
Branch {
|
||||||
|
action: Option<String>,
|
||||||
|
target: Option<String>,
|
||||||
|
},
|
||||||
Bughunter {
|
Bughunter {
|
||||||
scope: Option<String>,
|
scope: Option<String>,
|
||||||
},
|
},
|
||||||
|
Worktree {
|
||||||
|
action: Option<String>,
|
||||||
|
path: Option<String>,
|
||||||
|
branch: Option<String>,
|
||||||
|
},
|
||||||
Commit,
|
Commit,
|
||||||
|
CommitPushPr {
|
||||||
|
context: Option<String>,
|
||||||
|
},
|
||||||
Pr {
|
Pr {
|
||||||
context: Option<String>,
|
context: Option<String>,
|
||||||
},
|
},
|
||||||
@@ -301,10 +337,22 @@ impl SlashCommand {
|
|||||||
"help" => Self::Help,
|
"help" => Self::Help,
|
||||||
"status" => Self::Status,
|
"status" => Self::Status,
|
||||||
"compact" => Self::Compact,
|
"compact" => Self::Compact,
|
||||||
|
"branch" => Self::Branch {
|
||||||
|
action: parts.next().map(ToOwned::to_owned),
|
||||||
|
target: parts.next().map(ToOwned::to_owned),
|
||||||
|
},
|
||||||
"bughunter" => Self::Bughunter {
|
"bughunter" => Self::Bughunter {
|
||||||
scope: remainder_after_command(trimmed, command),
|
scope: remainder_after_command(trimmed, command),
|
||||||
},
|
},
|
||||||
|
"worktree" => Self::Worktree {
|
||||||
|
action: parts.next().map(ToOwned::to_owned),
|
||||||
|
path: parts.next().map(ToOwned::to_owned),
|
||||||
|
branch: parts.next().map(ToOwned::to_owned),
|
||||||
|
},
|
||||||
"commit" => Self::Commit,
|
"commit" => Self::Commit,
|
||||||
|
"commit-push-pr" => Self::CommitPushPr {
|
||||||
|
context: remainder_after_command(trimmed, command),
|
||||||
|
},
|
||||||
"pr" => Self::Pr {
|
"pr" => Self::Pr {
|
||||||
context: remainder_after_command(trimmed, command),
|
context: remainder_after_command(trimmed, command),
|
||||||
},
|
},
|
||||||
@@ -631,6 +679,392 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CommitPushPrRequest {
|
||||||
|
pub commit_message: Option<String>,
|
||||||
|
pub pr_title: String,
|
||||||
|
pub pr_body: String,
|
||||||
|
pub branch_name_hint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_branch_slash_command(
|
||||||
|
action: Option<&str>,
|
||||||
|
target: Option<&str>,
|
||||||
|
cwd: &Path,
|
||||||
|
) -> io::Result<String> {
|
||||||
|
match normalize_optional_args(action) {
|
||||||
|
None | Some("list") => {
|
||||||
|
let branches = git_stdout(cwd, &["branch", "--list", "--verbose"])?;
|
||||||
|
let trimmed = branches.trim();
|
||||||
|
Ok(if trimmed.is_empty() {
|
||||||
|
"Branch\n Result no branches found".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Branch\n Result listed\n\n{}", trimmed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some("create") => {
|
||||||
|
let Some(target) = target.filter(|value| !value.trim().is_empty()) else {
|
||||||
|
return Ok("Usage: /branch create <name>".to_string());
|
||||||
|
};
|
||||||
|
git_status_ok(cwd, &["switch", "-c", target])?;
|
||||||
|
Ok(format!(
|
||||||
|
"Branch\n Result created and switched\n Branch {target}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some("switch") => {
|
||||||
|
let Some(target) = target.filter(|value| !value.trim().is_empty()) else {
|
||||||
|
return Ok("Usage: /branch switch <name>".to_string());
|
||||||
|
};
|
||||||
|
git_status_ok(cwd, &["switch", target])?;
|
||||||
|
Ok(format!(
|
||||||
|
"Branch\n Result switched\n Branch {target}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some(other) => Ok(format!(
|
||||||
|
"Unknown /branch action '{other}'. Use /branch list, /branch create <name>, or /branch switch <name>."
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_worktree_slash_command(
|
||||||
|
action: Option<&str>,
|
||||||
|
path: Option<&str>,
|
||||||
|
branch: Option<&str>,
|
||||||
|
cwd: &Path,
|
||||||
|
) -> io::Result<String> {
|
||||||
|
match normalize_optional_args(action) {
|
||||||
|
None | Some("list") => {
|
||||||
|
let worktrees = git_stdout(cwd, &["worktree", "list"])?;
|
||||||
|
let trimmed = worktrees.trim();
|
||||||
|
Ok(if trimmed.is_empty() {
|
||||||
|
"Worktree\n Result no worktrees found".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Worktree\n Result listed\n\n{}", trimmed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some("add") => {
|
||||||
|
let Some(path) = path.filter(|value| !value.trim().is_empty()) else {
|
||||||
|
return Ok("Usage: /worktree add <path> [branch]".to_string());
|
||||||
|
};
|
||||||
|
if let Some(branch) = branch.filter(|value| !value.trim().is_empty()) {
|
||||||
|
if branch_exists(cwd, branch) {
|
||||||
|
git_status_ok(cwd, &["worktree", "add", path, branch])?;
|
||||||
|
} else {
|
||||||
|
git_status_ok(cwd, &["worktree", "add", path, "-b", branch])?;
|
||||||
|
}
|
||||||
|
Ok(format!(
|
||||||
|
"Worktree\n Result added\n Path {path}\n Branch {branch}"
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
git_status_ok(cwd, &["worktree", "add", path])?;
|
||||||
|
Ok(format!(
|
||||||
|
"Worktree\n Result added\n Path {path}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("remove") => {
|
||||||
|
let Some(path) = path.filter(|value| !value.trim().is_empty()) else {
|
||||||
|
return Ok("Usage: /worktree remove <path>".to_string());
|
||||||
|
};
|
||||||
|
git_status_ok(cwd, &["worktree", "remove", path])?;
|
||||||
|
Ok(format!(
|
||||||
|
"Worktree\n Result removed\n Path {path}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some("prune") => {
|
||||||
|
git_status_ok(cwd, &["worktree", "prune"])?;
|
||||||
|
Ok("Worktree\n Result pruned".to_string())
|
||||||
|
}
|
||||||
|
Some(other) => Ok(format!(
|
||||||
|
"Unknown /worktree action '{other}'. Use /worktree list, /worktree add <path> [branch], /worktree remove <path>, or /worktree prune."
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_commit_slash_command(message: &str, cwd: &Path) -> io::Result<String> {
|
||||||
|
let status = git_stdout(cwd, &["status", "--short"])?;
|
||||||
|
if status.trim().is_empty() {
|
||||||
|
return Ok(
|
||||||
|
"Commit\n Result skipped\n Reason no workspace changes"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = message.trim();
|
||||||
|
if message.is_empty() {
|
||||||
|
return Err(io::Error::other("generated commit message was empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
git_status_ok(cwd, &["add", "-A"])?;
|
||||||
|
let path = write_temp_text_file("claw-commit-message", "txt", message)?;
|
||||||
|
let path_string = path.to_string_lossy().into_owned();
|
||||||
|
git_status_ok(cwd, &["commit", "--file", path_string.as_str()])?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"Commit\n Result created\n Message file {}\n\n{}",
|
||||||
|
path.display(),
|
||||||
|
message
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_commit_push_pr_slash_command(
|
||||||
|
request: &CommitPushPrRequest,
|
||||||
|
cwd: &Path,
|
||||||
|
) -> io::Result<String> {
|
||||||
|
if !command_exists("gh") {
|
||||||
|
return Err(io::Error::other("gh CLI is required for /commit-push-pr"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_branch = detect_default_branch(cwd)?;
|
||||||
|
let mut branch = current_branch(cwd)?;
|
||||||
|
let mut created_branch = false;
|
||||||
|
if branch == default_branch {
|
||||||
|
let hint = if request.branch_name_hint.trim().is_empty() {
|
||||||
|
request.pr_title.as_str()
|
||||||
|
} else {
|
||||||
|
request.branch_name_hint.as_str()
|
||||||
|
};
|
||||||
|
let next_branch = build_branch_name(hint);
|
||||||
|
git_status_ok(cwd, &["switch", "-c", next_branch.as_str()])?;
|
||||||
|
branch = next_branch;
|
||||||
|
created_branch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let workspace_has_changes = !git_stdout(cwd, &["status", "--short"])?.trim().is_empty();
|
||||||
|
let commit_report = if workspace_has_changes {
|
||||||
|
let Some(message) = request.commit_message.as_deref() else {
|
||||||
|
return Err(io::Error::other(
|
||||||
|
"commit message is required when workspace changes are present",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Some(handle_commit_slash_command(message, cwd)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let branch_diff = git_stdout(
|
||||||
|
cwd,
|
||||||
|
&["diff", "--stat", &format!("{default_branch}...HEAD")],
|
||||||
|
)?;
|
||||||
|
if branch_diff.trim().is_empty() {
|
||||||
|
return Ok(
|
||||||
|
"Commit/Push/PR\n Result skipped\n Reason no branch changes to push or open as a pull request"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
git_status_ok(cwd, &["push", "--set-upstream", "origin", branch.as_str()])?;
|
||||||
|
|
||||||
|
let body_path = write_temp_text_file("claw-pr-body", "md", request.pr_body.trim())?;
|
||||||
|
let body_path_string = body_path.to_string_lossy().into_owned();
|
||||||
|
let create = Command::new("gh")
|
||||||
|
.args([
|
||||||
|
"pr",
|
||||||
|
"create",
|
||||||
|
"--title",
|
||||||
|
request.pr_title.as_str(),
|
||||||
|
"--body-file",
|
||||||
|
body_path_string.as_str(),
|
||||||
|
"--base",
|
||||||
|
default_branch.as_str(),
|
||||||
|
])
|
||||||
|
.current_dir(cwd)
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
let (result, url) = if create.status.success() {
|
||||||
|
(
|
||||||
|
"created",
|
||||||
|
parse_pr_url(&String::from_utf8_lossy(&create.stdout))
|
||||||
|
.unwrap_or_else(|| "<unknown>".to_string()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let view = Command::new("gh")
|
||||||
|
.args(["pr", "view", "--json", "url"])
|
||||||
|
.current_dir(cwd)
|
||||||
|
.output()?;
|
||||||
|
if !view.status.success() {
|
||||||
|
return Err(io::Error::other(command_failure(
|
||||||
|
"gh",
|
||||||
|
&["pr", "create"],
|
||||||
|
&create,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
(
|
||||||
|
"existing",
|
||||||
|
parse_pr_json_url(&String::from_utf8_lossy(&view.stdout))
|
||||||
|
.unwrap_or_else(|| "<unknown>".to_string()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines = vec![
|
||||||
|
"Commit/Push/PR".to_string(),
|
||||||
|
format!(" Result {result}"),
|
||||||
|
format!(" Branch {branch}"),
|
||||||
|
format!(" Base {default_branch}"),
|
||||||
|
format!(" Body file {}", body_path.display()),
|
||||||
|
format!(" URL {url}"),
|
||||||
|
];
|
||||||
|
if created_branch {
|
||||||
|
lines.insert(2, " Branch action created and switched".to_string());
|
||||||
|
}
|
||||||
|
if let Some(report) = commit_report {
|
||||||
|
lines.push(String::new());
|
||||||
|
lines.push(report);
|
||||||
|
}
|
||||||
|
Ok(lines.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_default_branch(cwd: &Path) -> io::Result<String> {
|
||||||
|
if let Ok(reference) = git_stdout(cwd, &["symbolic-ref", "refs/remotes/origin/HEAD"]) {
|
||||||
|
if let Some(branch) = reference
|
||||||
|
.trim()
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
return Ok(branch.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for branch in ["main", "master"] {
|
||||||
|
if branch_exists(cwd, branch) {
|
||||||
|
return Ok(branch.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current_branch(cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_stdout(cwd: &Path, args: &[&str]) -> io::Result<String> {
|
||||||
|
run_command_stdout("git", args, cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_status_ok(cwd: &Path, args: &[&str]) -> io::Result<()> {
|
||||||
|
run_command_success("git", args, cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_command_stdout(program: &str, args: &[&str], cwd: &Path) -> io::Result<String> {
|
||||||
|
let output = Command::new(program).args(args).current_dir(cwd).output()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(io::Error::other(command_failure(program, args, &output)));
|
||||||
|
}
|
||||||
|
String::from_utf8(output.stdout)
|
||||||
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_command_success(program: &str, args: &[&str], cwd: &Path) -> io::Result<()> {
|
||||||
|
let output = Command::new(program).args(args).current_dir(cwd).output()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(io::Error::other(command_failure(program, args, &output)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_failure(program: &str, args: &[&str], output: &std::process::Output) -> String {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let detail = if stderr.is_empty() { stdout } else { stderr };
|
||||||
|
if detail.is_empty() {
|
||||||
|
format!("{program} {} failed", args.join(" "))
|
||||||
|
} else {
|
||||||
|
format!("{program} {} failed: {detail}", args.join(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn branch_exists(cwd: &Path, branch: &str) -> bool {
|
||||||
|
Command::new("git")
|
||||||
|
.args([
|
||||||
|
"show-ref",
|
||||||
|
"--verify",
|
||||||
|
"--quiet",
|
||||||
|
&format!("refs/heads/{branch}"),
|
||||||
|
])
|
||||||
|
.current_dir(cwd)
|
||||||
|
.output()
|
||||||
|
.map(|output| output.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_branch(cwd: &Path) -> io::Result<String> {
|
||||||
|
let branch = git_stdout(cwd, &["branch", "--show-current"])?;
|
||||||
|
let branch = branch.trim();
|
||||||
|
if branch.is_empty() {
|
||||||
|
Err(io::Error::other("unable to determine current git branch"))
|
||||||
|
} else {
|
||||||
|
Ok(branch.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_exists(name: &str) -> bool {
|
||||||
|
Command::new(name)
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.map(|output| output.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_temp_text_file(prefix: &str, extension: &str, contents: &str) -> io::Result<PathBuf> {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_nanos())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let path = env::temp_dir().join(format!("{prefix}-{nanos}.{extension}"));
|
||||||
|
fs::write(&path, contents)?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_branch_name(hint: &str) -> String {
|
||||||
|
let slug = slugify(hint);
|
||||||
|
let owner = env::var("SAFEUSER")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
env::var("USER")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
});
|
||||||
|
match owner {
|
||||||
|
Some(owner) => format!("{owner}/{slug}"),
|
||||||
|
None => slug,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slugify(value: &str) -> String {
|
||||||
|
let mut slug = String::new();
|
||||||
|
let mut last_was_dash = false;
|
||||||
|
for ch in value.chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
slug.push(ch.to_ascii_lowercase());
|
||||||
|
last_was_dash = false;
|
||||||
|
} else if !last_was_dash {
|
||||||
|
slug.push('-');
|
||||||
|
last_was_dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let slug = slug.trim_matches('-').to_string();
|
||||||
|
if slug.is_empty() {
|
||||||
|
"change".to_string()
|
||||||
|
} else {
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_pr_url(stdout: &str) -> Option<String> {
|
||||||
|
stdout
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.find(|line| line.starts_with("http://") || line.starts_with("https://"))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_pr_json_url(stdout: &str) -> Option<String> {
|
||||||
|
serde_json::from_str::<serde_json::Value>(stdout)
|
||||||
|
.ok()?
|
||||||
|
.get("url")?
|
||||||
|
.as_str()
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
||||||
let mut lines = vec!["Plugins".to_string()];
|
let mut lines = vec!["Plugins".to_string()];
|
||||||
@@ -1181,8 +1615,11 @@ pub fn handle_slash_command(
|
|||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
}),
|
}),
|
||||||
SlashCommand::Status
|
SlashCommand::Status
|
||||||
|
| SlashCommand::Branch { .. }
|
||||||
| SlashCommand::Bughunter { .. }
|
| SlashCommand::Bughunter { .. }
|
||||||
|
| SlashCommand::Worktree { .. }
|
||||||
| SlashCommand::Commit
|
| SlashCommand::Commit
|
||||||
|
| SlashCommand::CommitPushPr { .. }
|
||||||
| SlashCommand::Pr { .. }
|
| SlashCommand::Pr { .. }
|
||||||
| SlashCommand::Issue { .. }
|
| SlashCommand::Issue { .. }
|
||||||
| SlashCommand::Ultraplan { .. }
|
| SlashCommand::Ultraplan { .. }
|
||||||
@@ -1210,17 +1647,25 @@ pub fn handle_slash_command(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
handle_plugins_slash_command, handle_slash_command, load_agents_from_roots,
|
handle_branch_slash_command, handle_commit_push_pr_slash_command,
|
||||||
load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report,
|
handle_commit_slash_command, handle_plugins_slash_command, handle_slash_command,
|
||||||
|
handle_worktree_slash_command, load_agents_from_roots, load_skills_from_roots,
|
||||||
|
render_agents_report, render_plugins_report, render_skills_report,
|
||||||
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
||||||
DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
|
CommitPushPrRequest, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
|
||||||
};
|
};
|
||||||
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
||||||
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
||||||
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
fn temp_dir(label: &str) -> PathBuf {
|
fn temp_dir(label: &str) -> PathBuf {
|
||||||
let nanos = SystemTime::now()
|
let nanos = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -1229,6 +1674,91 @@ mod tests {
|
|||||||
std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
|
std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||||
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
|
.lock()
|
||||||
|
.expect("env lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_command(cwd: &Path, program: &str, args: &[&str]) -> String {
|
||||||
|
let output = Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.current_dir(cwd)
|
||||||
|
.output()
|
||||||
|
.expect("command should run");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{} {} failed: {}",
|
||||||
|
program,
|
||||||
|
args.join(" "),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
String::from_utf8(output.stdout).expect("stdout should be utf8")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_git_repo(label: &str) -> PathBuf {
|
||||||
|
let root = temp_dir(label);
|
||||||
|
fs::create_dir_all(&root).expect("repo root");
|
||||||
|
|
||||||
|
let init = Command::new("git")
|
||||||
|
.args(["init", "-b", "main"])
|
||||||
|
.current_dir(&root)
|
||||||
|
.output()
|
||||||
|
.expect("git init should run");
|
||||||
|
if !init.status.success() {
|
||||||
|
let fallback = Command::new("git")
|
||||||
|
.arg("init")
|
||||||
|
.current_dir(&root)
|
||||||
|
.output()
|
||||||
|
.expect("fallback git init should run");
|
||||||
|
assert!(
|
||||||
|
fallback.status.success(),
|
||||||
|
"fallback git init should succeed"
|
||||||
|
);
|
||||||
|
let rename = Command::new("git")
|
||||||
|
.args(["branch", "-m", "main"])
|
||||||
|
.current_dir(&root)
|
||||||
|
.output()
|
||||||
|
.expect("git branch -m should run");
|
||||||
|
assert!(rename.status.success(), "git branch -m main should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
run_command(&root, "git", &["config", "user.name", "Claw Tests"]);
|
||||||
|
run_command(&root, "git", &["config", "user.email", "claw@example.com"]);
|
||||||
|
fs::write(root.join("README.md"), "seed\n").expect("seed file");
|
||||||
|
run_command(&root, "git", &["add", "README.md"]);
|
||||||
|
run_command(&root, "git", &["commit", "-m", "chore: seed repo"]);
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_bare_repo(label: &str) -> PathBuf {
|
||||||
|
let root = temp_dir(label);
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["init", "--bare"])
|
||||||
|
.arg(&root)
|
||||||
|
.output()
|
||||||
|
.expect("bare repo should initialize");
|
||||||
|
assert!(output.status.success(), "git init --bare should succeed");
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn write_fake_gh(bin_dir: &Path, log_path: &Path, url: &str) {
|
||||||
|
fs::create_dir_all(bin_dir).expect("bin dir");
|
||||||
|
let script = format!(
|
||||||
|
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'gh 1.0.0'\n exit 0\nfi\nprintf '%s\\n' \"$*\" >> \"{}\"\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"create\" ]; then\n echo '{}'\n exit 0\nfi\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"view\" ]; then\n echo '{{\"url\":\"{}\"}}'\n exit 0\nfi\nexit 0\n",
|
||||||
|
log_path.display(),
|
||||||
|
url,
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
let path = bin_dir.join("gh");
|
||||||
|
fs::write(&path, script).expect("gh stub");
|
||||||
|
let mut permissions = fs::metadata(&path).expect("metadata").permissions();
|
||||||
|
permissions.set_mode(0o755);
|
||||||
|
fs::set_permissions(&path, permissions).expect("chmod");
|
||||||
|
}
|
||||||
|
|
||||||
fn write_external_plugin(root: &Path, name: &str, version: &str) {
|
fn write_external_plugin(root: &Path, name: &str, version: &str) {
|
||||||
fs::create_dir_all(root.join(".claw-plugin")).expect("manifest dir");
|
fs::create_dir_all(root.join(".claw-plugin")).expect("manifest dir");
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -1293,7 +1823,28 @@ mod tests {
|
|||||||
scope: Some("runtime".to_string())
|
scope: Some("runtime".to_string())
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/branch create feature/demo"),
|
||||||
|
Some(SlashCommand::Branch {
|
||||||
|
action: Some("create".to_string()),
|
||||||
|
target: Some("feature/demo".to_string()),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/worktree add ../demo wt-demo"),
|
||||||
|
Some(SlashCommand::Worktree {
|
||||||
|
action: Some("add".to_string()),
|
||||||
|
path: Some("../demo".to_string()),
|
||||||
|
branch: Some("wt-demo".to_string()),
|
||||||
|
})
|
||||||
|
);
|
||||||
assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
|
assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/commit-push-pr ready for review"),
|
||||||
|
Some(SlashCommand::CommitPushPr {
|
||||||
|
context: Some("ready for review".to_string())
|
||||||
|
})
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SlashCommand::parse("/pr ready for review"),
|
SlashCommand::parse("/pr ready for review"),
|
||||||
Some(SlashCommand::Pr {
|
Some(SlashCommand::Pr {
|
||||||
@@ -1418,7 +1969,10 @@ mod tests {
|
|||||||
assert!(help.contains("/status"));
|
assert!(help.contains("/status"));
|
||||||
assert!(help.contains("/compact"));
|
assert!(help.contains("/compact"));
|
||||||
assert!(help.contains("/bughunter [scope]"));
|
assert!(help.contains("/bughunter [scope]"));
|
||||||
|
assert!(help.contains("/branch [list|create <name>|switch <name>]"));
|
||||||
|
assert!(help.contains("/worktree [list|add <path> [branch]|remove <path>|prune]"));
|
||||||
assert!(help.contains("/commit"));
|
assert!(help.contains("/commit"));
|
||||||
|
assert!(help.contains("/commit-push-pr [context]"));
|
||||||
assert!(help.contains("/pr [context]"));
|
assert!(help.contains("/pr [context]"));
|
||||||
assert!(help.contains("/issue [context]"));
|
assert!(help.contains("/issue [context]"));
|
||||||
assert!(help.contains("/ultraplan [task]"));
|
assert!(help.contains("/ultraplan [task]"));
|
||||||
@@ -1442,7 +1996,7 @@ mod tests {
|
|||||||
assert!(help.contains("aliases: /plugins, /marketplace"));
|
assert!(help.contains("aliases: /plugins, /marketplace"));
|
||||||
assert!(help.contains("/agents"));
|
assert!(help.contains("/agents"));
|
||||||
assert!(help.contains("/skills"));
|
assert!(help.contains("/skills"));
|
||||||
assert_eq!(slash_command_specs().len(), 25);
|
assert_eq!(slash_command_specs().len(), 28);
|
||||||
assert_eq!(resume_supported_slash_commands().len(), 13);
|
assert_eq!(resume_supported_slash_commands().len(), 13);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1490,10 +2044,22 @@ mod tests {
|
|||||||
let session = Session::new();
|
let session = Session::new();
|
||||||
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
|
||||||
|
assert!(
|
||||||
|
handle_slash_command("/branch list", &session, CompactionConfig::default()).is_none()
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
|
handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
handle_slash_command("/worktree list", &session, CompactionConfig::default()).is_none()
|
||||||
|
);
|
||||||
assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
|
||||||
|
assert!(handle_slash_command(
|
||||||
|
"/commit-push-pr review notes",
|
||||||
|
&session,
|
||||||
|
CompactionConfig::default()
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
|
assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1805,4 +2371,141 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(config_home);
|
let _ = fs::remove_dir_all(config_home);
|
||||||
let _ = fs::remove_dir_all(bundled_root);
|
let _ = fs::remove_dir_all(bundled_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn branch_and_worktree_commands_manage_git_state() {
|
||||||
|
// given
|
||||||
|
let repo = init_git_repo("branch-worktree");
|
||||||
|
let worktree_path = repo
|
||||||
|
.parent()
|
||||||
|
.expect("repo should have parent")
|
||||||
|
.join("branch-worktree-linked");
|
||||||
|
|
||||||
|
// when
|
||||||
|
let branch_list =
|
||||||
|
handle_branch_slash_command(Some("list"), None, &repo).expect("branch list succeeds");
|
||||||
|
let created = handle_branch_slash_command(Some("create"), Some("feature/demo"), &repo)
|
||||||
|
.expect("branch create succeeds");
|
||||||
|
let switched = handle_branch_slash_command(Some("switch"), Some("main"), &repo)
|
||||||
|
.expect("branch switch succeeds");
|
||||||
|
let added = handle_worktree_slash_command(
|
||||||
|
Some("add"),
|
||||||
|
Some(worktree_path.to_str().expect("utf8 path")),
|
||||||
|
Some("wt-demo"),
|
||||||
|
&repo,
|
||||||
|
)
|
||||||
|
.expect("worktree add succeeds");
|
||||||
|
let listed_worktrees =
|
||||||
|
handle_worktree_slash_command(Some("list"), None, None, &repo).expect("list succeeds");
|
||||||
|
let removed = handle_worktree_slash_command(
|
||||||
|
Some("remove"),
|
||||||
|
Some(worktree_path.to_str().expect("utf8 path")),
|
||||||
|
None,
|
||||||
|
&repo,
|
||||||
|
)
|
||||||
|
.expect("remove succeeds");
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert!(branch_list.contains("main"));
|
||||||
|
assert!(created.contains("feature/demo"));
|
||||||
|
assert!(switched.contains("main"));
|
||||||
|
assert!(added.contains("wt-demo"));
|
||||||
|
assert!(listed_worktrees.contains(worktree_path.to_str().expect("utf8 path")));
|
||||||
|
assert!(removed.contains("Result removed"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(repo);
|
||||||
|
let _ = fs::remove_dir_all(worktree_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_command_stages_and_commits_changes() {
|
||||||
|
// given
|
||||||
|
let repo = init_git_repo("commit-command");
|
||||||
|
fs::write(repo.join("notes.txt"), "hello\n").expect("write notes");
|
||||||
|
|
||||||
|
// when
|
||||||
|
let report =
|
||||||
|
handle_commit_slash_command("feat: add notes", &repo).expect("commit succeeds");
|
||||||
|
let status = run_command(&repo, "git", &["status", "--short"]);
|
||||||
|
let message = run_command(&repo, "git", &["log", "-1", "--pretty=%B"]);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert!(report.contains("Result created"));
|
||||||
|
assert!(status.trim().is_empty());
|
||||||
|
assert_eq!(message.trim(), "feat: add notes");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn commit_push_pr_command_commits_pushes_and_creates_pr() {
|
||||||
|
// given
|
||||||
|
let _guard = env_lock();
|
||||||
|
let repo = init_git_repo("commit-push-pr");
|
||||||
|
let remote = init_bare_repo("commit-push-pr-remote");
|
||||||
|
run_command(
|
||||||
|
&repo,
|
||||||
|
"git",
|
||||||
|
&[
|
||||||
|
"remote",
|
||||||
|
"add",
|
||||||
|
"origin",
|
||||||
|
remote.to_str().expect("utf8 remote"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
run_command(&repo, "git", &["push", "-u", "origin", "main"]);
|
||||||
|
fs::write(repo.join("feature.txt"), "feature\n").expect("write feature file");
|
||||||
|
|
||||||
|
let fake_bin = temp_dir("fake-gh-bin");
|
||||||
|
let gh_log = fake_bin.join("gh.log");
|
||||||
|
write_fake_gh(&fake_bin, &gh_log, "https://example.com/pr/123");
|
||||||
|
|
||||||
|
let previous_path = env::var_os("PATH");
|
||||||
|
let mut new_path = fake_bin.display().to_string();
|
||||||
|
if let Some(path) = &previous_path {
|
||||||
|
new_path.push(':');
|
||||||
|
new_path.push_str(&path.to_string_lossy());
|
||||||
|
}
|
||||||
|
env::set_var("PATH", &new_path);
|
||||||
|
let previous_safeuser = env::var_os("SAFEUSER");
|
||||||
|
env::set_var("SAFEUSER", "tester");
|
||||||
|
|
||||||
|
let request = CommitPushPrRequest {
|
||||||
|
commit_message: Some("feat: add feature file".to_string()),
|
||||||
|
pr_title: "Add feature file".to_string(),
|
||||||
|
pr_body: "## Summary\n- add feature file".to_string(),
|
||||||
|
branch_name_hint: "Add feature file".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// when
|
||||||
|
let report =
|
||||||
|
handle_commit_push_pr_slash_command(&request, &repo).expect("commit-push-pr succeeds");
|
||||||
|
let branch = run_command(&repo, "git", &["branch", "--show-current"]);
|
||||||
|
let message = run_command(&repo, "git", &["log", "-1", "--pretty=%B"]);
|
||||||
|
let gh_invocations = fs::read_to_string(&gh_log).expect("gh log should exist");
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert!(report.contains("Result created"));
|
||||||
|
assert!(report.contains("URL https://example.com/pr/123"));
|
||||||
|
assert_eq!(branch.trim(), "tester/add-feature-file");
|
||||||
|
assert_eq!(message.trim(), "feat: add feature file");
|
||||||
|
assert!(gh_invocations.contains("pr create"));
|
||||||
|
assert!(gh_invocations.contains("--base main"));
|
||||||
|
|
||||||
|
if let Some(path) = previous_path {
|
||||||
|
env::set_var("PATH", path);
|
||||||
|
} else {
|
||||||
|
env::remove_var("PATH");
|
||||||
|
}
|
||||||
|
if let Some(safeuser) = previous_safeuser {
|
||||||
|
env::set_var("SAFEUSER", safeuser);
|
||||||
|
} else {
|
||||||
|
env::remove_var("SAFEUSER");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(repo);
|
||||||
|
let _ = fs::remove_dir_all(remote);
|
||||||
|
let _ = fs::remove_dir_all(fake_bin);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user