Story 33: Copy-paste diff commands for agent worktrees
- Add base_branch detection to WorktreeInfo (from project root HEAD)
- Expose base_branch in AgentInfo API response
- Add {{base_branch}} template variable to agent config rendering
- Show git difftool command with copy-to-clipboard in AgentPanel UI
- Add diff command instruction to coder agent prompts
- Add AgentPanel tests for diff command rendering and clipboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,7 @@ role = "Full-stack engineer. Implements features across all components."
|
|||||||
model = "sonnet"
|
model = "sonnet"
|
||||||
max_turns = 50
|
max_turns = 50
|
||||||
max_budget_usd = 5.00
|
max_budget_usd = 5.00
|
||||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/stories/ - move it to current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop."
|
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/stories/ - move it to current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD"
|
||||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story."
|
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story."
|
||||||
|
|
||||||
[[agent]]
|
[[agent]]
|
||||||
@@ -63,5 +63,5 @@ role = "Full-stack engineer. Implements features across all components."
|
|||||||
model = "sonnet"
|
model = "sonnet"
|
||||||
max_turns = 50
|
max_turns = 50
|
||||||
max_budget_usd = 5.00
|
max_budget_usd = 5.00
|
||||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/stories/ - move it to current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop."
|
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/stories/ - move it to current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD"
|
||||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story."
|
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story."
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface AgentInfo {
|
|||||||
status: AgentStatusValue;
|
status: AgentStatusValue;
|
||||||
session_id: string | null;
|
session_id: string | null;
|
||||||
worktree_path: string | null;
|
worktree_path: string | null;
|
||||||
|
base_branch: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentEvent {
|
export interface AgentEvent {
|
||||||
|
|||||||
186
frontend/src/components/AgentPanel.test.tsx
Normal file
186
frontend/src/components/AgentPanel.test.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AgentInfo, AgentConfigInfo } from "../api/agents";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
|
|
||||||
|
vi.mock("../api/agents", () => {
|
||||||
|
const agentsApi = {
|
||||||
|
listAgents: vi.fn(),
|
||||||
|
getAgentConfig: vi.fn(),
|
||||||
|
startAgent: vi.fn(),
|
||||||
|
stopAgent: vi.fn(),
|
||||||
|
reloadConfig: vi.fn(),
|
||||||
|
};
|
||||||
|
return { agentsApi, subscribeAgentStream: vi.fn(() => () => {}) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dynamic import so the mock is in place before the module loads
|
||||||
|
const { AgentPanel } = await import("./AgentPanel");
|
||||||
|
|
||||||
|
const mockedAgents = {
|
||||||
|
listAgents: vi.mocked(agentsApi.listAgents),
|
||||||
|
getAgentConfig: vi.mocked(agentsApi.getAgentConfig),
|
||||||
|
startAgent: vi.mocked(agentsApi.startAgent),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROSTER: AgentConfigInfo[] = [
|
||||||
|
{
|
||||||
|
name: "coder-1",
|
||||||
|
role: "Full-stack engineer",
|
||||||
|
model: "sonnet",
|
||||||
|
allowed_tools: null,
|
||||||
|
max_turns: 50,
|
||||||
|
max_budget_usd: 5.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("AgentPanel diff command", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedAgents.getAgentConfig.mockResolvedValue(ROSTER);
|
||||||
|
mockedAgents.listAgents.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows diff command when an agent has a worktree path", async () => {
|
||||||
|
const agentList: AgentInfo[] = [
|
||||||
|
{
|
||||||
|
story_id: "33_diff_commands",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
status: "running",
|
||||||
|
session_id: null,
|
||||||
|
worktree_path: "/tmp/project-story-33",
|
||||||
|
base_branch: "master",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentPanel
|
||||||
|
stories={[
|
||||||
|
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expand the agent detail by clicking the expand button
|
||||||
|
const expandButton = await screen.findByText("▶");
|
||||||
|
await userEvent.click(expandButton);
|
||||||
|
|
||||||
|
// The diff command should be rendered
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
'cd "/tmp/project-story-33" && git difftool master...HEAD',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// A copy button should exist
|
||||||
|
expect(screen.getByText("Copy")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies diff command to clipboard on click", async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText },
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentList: AgentInfo[] = [
|
||||||
|
{
|
||||||
|
story_id: "33_diff_commands",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
status: "completed",
|
||||||
|
session_id: null,
|
||||||
|
worktree_path: "/home/user/my-project-story-33",
|
||||||
|
base_branch: "main",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentPanel
|
||||||
|
stories={[
|
||||||
|
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const expandButton = await screen.findByText("▶");
|
||||||
|
await userEvent.click(expandButton);
|
||||||
|
|
||||||
|
const copyButton = await screen.findByText("Copy");
|
||||||
|
await userEvent.click(copyButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(writeText).toHaveBeenCalledWith(
|
||||||
|
'cd "/home/user/my-project-story-33" && git difftool main...HEAD',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Copied")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses base_branch from the server in the diff command", async () => {
|
||||||
|
const agentList: AgentInfo[] = [
|
||||||
|
{
|
||||||
|
story_id: "33_diff_commands",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
status: "running",
|
||||||
|
session_id: null,
|
||||||
|
worktree_path: "/tmp/wt",
|
||||||
|
base_branch: "develop",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentPanel
|
||||||
|
stories={[
|
||||||
|
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const expandButton = await screen.findByText("▶");
|
||||||
|
await userEvent.click(expandButton);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
'cd "/tmp/wt" && git difftool develop...HEAD',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to master when base_branch is null", async () => {
|
||||||
|
const agentList: AgentInfo[] = [
|
||||||
|
{
|
||||||
|
story_id: "33_diff_commands",
|
||||||
|
agent_name: "coder-1",
|
||||||
|
status: "running",
|
||||||
|
session_id: null,
|
||||||
|
worktree_path: "/tmp/wt",
|
||||||
|
base_branch: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockedAgents.listAgents.mockResolvedValue(agentList);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentPanel
|
||||||
|
stories={[
|
||||||
|
{ story_id: "33_diff_commands", name: "Diff Commands", error: null },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const expandButton = await screen.findByText("▶");
|
||||||
|
await userEvent.click(expandButton);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
'cd "/tmp/wt" && git difftool master...HEAD',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ interface AgentState {
|
|||||||
log: string[];
|
log: string[];
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
worktreePath: string | null;
|
worktreePath: string | null;
|
||||||
|
baseBranch: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<AgentStatusValue, string> = {
|
const STATUS_COLORS: Record<AgentStatusValue, string> = {
|
||||||
@@ -104,6 +105,69 @@ function agentKey(storyId: string, agentName: string): string {
|
|||||||
return `${storyId}:${agentName}`;
|
return `${storyId}:${agentName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DiffCommand({
|
||||||
|
worktreePath,
|
||||||
|
baseBranch,
|
||||||
|
}: { worktreePath: string; baseBranch: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const command = `cd "${worktreePath}" && git difftool ${baseBranch}...HEAD`;
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(command);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Fallback: select text for manual copy
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: "0.7em",
|
||||||
|
color: "#8b949e",
|
||||||
|
background: "#0d1117",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #21262d",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{command}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
style={{
|
||||||
|
padding: "3px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #30363d",
|
||||||
|
background: copied ? "#238636" : "#21262d",
|
||||||
|
color: copied ? "#fff" : "#8b949e",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.7em",
|
||||||
|
fontWeight: 600,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentPanel({ stories }: AgentPanelProps) {
|
export function AgentPanel({ stories }: AgentPanelProps) {
|
||||||
const [agents, setAgents] = useState<Record<string, AgentState>>({});
|
const [agents, setAgents] = useState<Record<string, AgentState>>({});
|
||||||
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
|
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
|
||||||
@@ -133,6 +197,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
log: [],
|
log: [],
|
||||||
sessionId: a.session_id,
|
sessionId: a.session_id,
|
||||||
worktreePath: a.worktree_path,
|
worktreePath: a.worktree_path,
|
||||||
|
baseBranch: a.base_branch,
|
||||||
};
|
};
|
||||||
if (a.status === "running" || a.status === "pending") {
|
if (a.status === "running" || a.status === "pending") {
|
||||||
subscribeToAgent(a.story_id, a.agent_name);
|
subscribeToAgent(a.story_id, a.agent_name);
|
||||||
@@ -166,6 +231,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
log: [],
|
log: [],
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
worktreePath: null,
|
worktreePath: null,
|
||||||
|
baseBranch: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@@ -241,6 +307,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
log: [],
|
log: [],
|
||||||
sessionId: info.session_id,
|
sessionId: info.session_id,
|
||||||
worktreePath: info.worktree_path,
|
worktreePath: info.worktree_path,
|
||||||
|
baseBranch: info.base_branch,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
setExpandedKey(key);
|
setExpandedKey(key);
|
||||||
@@ -609,6 +676,12 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
Worktree: {a.worktreePath}
|
Worktree: {a.worktreePath}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{a.worktreePath && (
|
||||||
|
<DiffCommand
|
||||||
|
worktreePath={a.worktreePath}
|
||||||
|
baseBranch={a.baseBranch ?? "master"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
maxHeight: "300px",
|
maxHeight: "300px",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ pub struct AgentInfo {
|
|||||||
pub status: AgentStatus,
|
pub status: AgentStatus,
|
||||||
pub session_id: Option<String>,
|
pub session_id: Option<String>,
|
||||||
pub worktree_path: Option<String>,
|
pub worktree_path: Option<String>,
|
||||||
|
pub base_branch: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StoryAgent {
|
struct StoryAgent {
|
||||||
@@ -182,7 +183,7 @@ impl AgentPool {
|
|||||||
// Spawn the agent process
|
// Spawn the agent process
|
||||||
let wt_path_str = wt_info.path.to_string_lossy().to_string();
|
let wt_path_str = wt_info.path.to_string_lossy().to_string();
|
||||||
let (command, args, prompt) =
|
let (command, args, prompt) =
|
||||||
config.render_agent_args(&wt_path_str, story_id, Some(&resolved_name))?;
|
config.render_agent_args(&wt_path_str, story_id, Some(&resolved_name), Some(&wt_info.base_branch))?;
|
||||||
|
|
||||||
let sid = story_id.to_string();
|
let sid = story_id.to_string();
|
||||||
let aname = resolved_name.clone();
|
let aname = resolved_name.clone();
|
||||||
@@ -247,6 +248,7 @@ impl AgentPool {
|
|||||||
status: AgentStatus::Running,
|
status: AgentStatus::Running,
|
||||||
session_id: None,
|
session_id: None,
|
||||||
worktree_path: Some(wt_path_str),
|
worktree_path: Some(wt_path_str),
|
||||||
|
base_branch: Some(wt_info.base_branch.clone()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,6 +323,10 @@ impl AgentPool {
|
|||||||
.worktree_info
|
.worktree_info
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|wt| wt.path.to_string_lossy().to_string()),
|
.map(|wt| wt.path.to_string_lossy().to_string()),
|
||||||
|
base_branch: agent
|
||||||
|
.worktree_info
|
||||||
|
.as_ref()
|
||||||
|
.map(|wt| wt.base_branch.clone()),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ impl ProjectConfig {
|
|||||||
worktree_path: &str,
|
worktree_path: &str,
|
||||||
story_id: &str,
|
story_id: &str,
|
||||||
agent_name: Option<&str>,
|
agent_name: Option<&str>,
|
||||||
|
base_branch: Option<&str>,
|
||||||
) -> Result<(String, Vec<String>, String), String> {
|
) -> Result<(String, Vec<String>, String), String> {
|
||||||
let agent = match agent_name {
|
let agent = match agent_name {
|
||||||
Some(name) => self
|
Some(name) => self
|
||||||
@@ -185,9 +186,11 @@ impl ProjectConfig {
|
|||||||
.ok_or_else(|| "No agents configured".to_string())?,
|
.ok_or_else(|| "No agents configured".to_string())?,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let bb = base_branch.unwrap_or("master");
|
||||||
let render = |s: &str| {
|
let render = |s: &str| {
|
||||||
s.replace("{{worktree_path}}", worktree_path)
|
s.replace("{{worktree_path}}", worktree_path)
|
||||||
.replace("{{story_id}}", story_id)
|
.replace("{{story_id}}", story_id)
|
||||||
|
.replace("{{base_branch}}", bb)
|
||||||
};
|
};
|
||||||
|
|
||||||
let command = render(&agent.command);
|
let command = render(&agent.command);
|
||||||
@@ -378,7 +381,7 @@ max_turns = 0
|
|||||||
fn render_agent_args_default() {
|
fn render_agent_args_default() {
|
||||||
let config = ProjectConfig::default();
|
let config = ProjectConfig::default();
|
||||||
let (cmd, args, prompt) = config
|
let (cmd, args, prompt) = config
|
||||||
.render_agent_args("/tmp/wt", "42_foo", None)
|
.render_agent_args("/tmp/wt", "42_foo", None, None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(cmd, "claude");
|
assert_eq!(cmd, "claude");
|
||||||
assert!(args.is_empty());
|
assert!(args.is_empty());
|
||||||
@@ -404,7 +407,7 @@ max_turns = 30
|
|||||||
|
|
||||||
let config = ProjectConfig::parse(toml_str).unwrap();
|
let config = ProjectConfig::parse(toml_str).unwrap();
|
||||||
let (cmd, args, prompt) = config
|
let (cmd, args, prompt) = config
|
||||||
.render_agent_args("/tmp/wt", "42_foo", Some("supervisor"))
|
.render_agent_args("/tmp/wt", "42_foo", Some("supervisor"), Some("master"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(cmd, "claude");
|
assert_eq!(cmd, "claude");
|
||||||
assert!(args.contains(&"--model".to_string()));
|
assert!(args.contains(&"--model".to_string()));
|
||||||
@@ -422,7 +425,7 @@ max_turns = 30
|
|||||||
|
|
||||||
// Render for coder
|
// Render for coder
|
||||||
let (_, coder_args, _) = config
|
let (_, coder_args, _) = config
|
||||||
.render_agent_args("/tmp/wt", "42_foo", Some("coder"))
|
.render_agent_args("/tmp/wt", "42_foo", Some("coder"), Some("master"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(coder_args.contains(&"sonnet".to_string()));
|
assert!(coder_args.contains(&"sonnet".to_string()));
|
||||||
assert!(coder_args.contains(&"30".to_string()));
|
assert!(coder_args.contains(&"30".to_string()));
|
||||||
@@ -433,7 +436,7 @@ max_turns = 30
|
|||||||
#[test]
|
#[test]
|
||||||
fn render_agent_args_not_found() {
|
fn render_agent_args_not_found() {
|
||||||
let config = ProjectConfig::default();
|
let config = ProjectConfig::default();
|
||||||
let result = config.render_agent_args("/tmp/wt", "42_foo", Some("nonexistent"));
|
let result = config.render_agent_args("/tmp/wt", "42_foo", Some("nonexistent"), None);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(result.unwrap_err().contains("No agent named 'nonexistent'"));
|
assert!(result.unwrap_err().contains("No agent named 'nonexistent'"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::process::Command;
|
|||||||
pub struct WorktreeInfo {
|
pub struct WorktreeInfo {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub branch: String,
|
pub branch: String,
|
||||||
|
pub base_branch: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Worktree path as a sibling of the project root: `{project_root}-story-{id}`.
|
/// Worktree path as a sibling of the project root: `{project_root}-story-{id}`.
|
||||||
@@ -24,6 +25,23 @@ fn branch_name(story_id: &str) -> String {
|
|||||||
format!("feature/story-{story_id}")
|
format!("feature/story-{story_id}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect the current branch of the project root (the base branch worktrees fork from).
|
||||||
|
fn detect_base_branch(project_root: &Path) -> String {
|
||||||
|
Command::new("git")
|
||||||
|
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||||
|
.current_dir(project_root)
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| {
|
||||||
|
if o.status.success() {
|
||||||
|
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "master".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a git worktree for the given story.
|
/// Create a git worktree for the given story.
|
||||||
///
|
///
|
||||||
/// - Creates the worktree at `{project_root}-story-{story_id}` (sibling directory)
|
/// - Creates the worktree at `{project_root}-story-{story_id}` (sibling directory)
|
||||||
@@ -37,6 +55,7 @@ pub async fn create_worktree(
|
|||||||
) -> Result<WorktreeInfo, String> {
|
) -> Result<WorktreeInfo, String> {
|
||||||
let wt_path = worktree_path(project_root, story_id);
|
let wt_path = worktree_path(project_root, story_id);
|
||||||
let branch = branch_name(story_id);
|
let branch = branch_name(story_id);
|
||||||
|
let base_branch = detect_base_branch(project_root);
|
||||||
let root = project_root.to_path_buf();
|
let root = project_root.to_path_buf();
|
||||||
|
|
||||||
// Already exists — reuse
|
// Already exists — reuse
|
||||||
@@ -45,6 +64,7 @@ pub async fn create_worktree(
|
|||||||
return Ok(WorktreeInfo {
|
return Ok(WorktreeInfo {
|
||||||
path: wt_path,
|
path: wt_path,
|
||||||
branch,
|
branch,
|
||||||
|
base_branch,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +80,7 @@ pub async fn create_worktree(
|
|||||||
Ok(WorktreeInfo {
|
Ok(WorktreeInfo {
|
||||||
path: wt_path,
|
path: wt_path,
|
||||||
branch,
|
branch,
|
||||||
|
base_branch,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user