feat(story-115): hot-reload project.toml agent config without server restart
- Extend `WatcherEvent` to an enum with `WorkItem` and `ConfigChanged` variants so the watcher can distinguish between pipeline-file changes and config changes - Watch `.story_kit/project.toml` at the project root (ignoring worktree copies) and broadcast `WatcherEvent::ConfigChanged` on modification - Forward `agent_config_changed` WebSocket message to connected clients; skip pipeline state refresh for config-only events - Add `is_config_file()` helper with unit tests covering root vs. worktree paths - Accept `configVersion` prop in `AgentPanel` and re-fetch the agent roster whenever it increments - Increment `agentConfigVersion` in `Chat` on receipt of `agent_config_changed` WS event via new `onAgentConfigChanged` handler in `ChatWebSocket` Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: "Hot-reload project.toml agent config without server restart"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 115: Hot-reload project.toml agent config without server restart
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a developer, I want changes to `.story_kit/project.toml` to be picked up automatically by the running server, so that I can update the agent roster without restarting the server.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] When `.story_kit/project.toml` is saved on disk, the server detects the change within the debounce window (300 ms) and broadcasts an `agent_config_changed` WebSocket event to all connected clients
|
||||||
|
- [ ] The frontend `AgentPanel` automatically re-fetches and displays the updated agent roster upon receiving `agent_config_changed`, without any manual action
|
||||||
|
- [ ] `project.toml` changes inside worktree directories (paths containing `worktrees/`) are NOT broadcast
|
||||||
|
- [ ] Config file changes do NOT trigger a pipeline state refresh (only work-item events do)
|
||||||
|
- [ ] A helper `is_config_file(path, git_root)` correctly identifies the root-level `.story_kit/project.toml` (returns false for worktree copies)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Watching for newly created `project.toml` (only file modification events)
|
||||||
|
- Validating the new config before broadcasting (parse errors are surfaced on next `get_agent_config` call)
|
||||||
|
- Reloading config into in-memory agent state (agents already read config from disk on each start)
|
||||||
@@ -57,7 +57,9 @@ export type WsResponse =
|
|||||||
story_id: string;
|
story_id: string;
|
||||||
status: string;
|
status: string;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
}
|
||||||
|
/** `.story_kit/project.toml` was modified; re-fetch the agent roster. */
|
||||||
|
| { type: "agent_config_changed" };
|
||||||
|
|
||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -273,6 +275,7 @@ export class ChatWebSocket {
|
|||||||
status: string,
|
status: string,
|
||||||
message: string,
|
message: string,
|
||||||
) => void;
|
) => void;
|
||||||
|
private onAgentConfigChanged?: () => void;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private closeTimer?: number;
|
private closeTimer?: number;
|
||||||
private wsPath = DEFAULT_WS_PATH;
|
private wsPath = DEFAULT_WS_PATH;
|
||||||
@@ -322,6 +325,7 @@ export class ChatWebSocket {
|
|||||||
data.status,
|
data.status,
|
||||||
data.message,
|
data.message,
|
||||||
);
|
);
|
||||||
|
if (data.type === "agent_config_changed") this.onAgentConfigChanged?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.onError?.(String(err));
|
this.onError?.(String(err));
|
||||||
}
|
}
|
||||||
@@ -367,6 +371,7 @@ export class ChatWebSocket {
|
|||||||
status: string,
|
status: string,
|
||||||
message: string,
|
message: string,
|
||||||
) => void;
|
) => void;
|
||||||
|
onAgentConfigChanged?: () => void;
|
||||||
},
|
},
|
||||||
wsPath = DEFAULT_WS_PATH,
|
wsPath = DEFAULT_WS_PATH,
|
||||||
) {
|
) {
|
||||||
@@ -378,6 +383,7 @@ export class ChatWebSocket {
|
|||||||
this.onPermissionRequest = handlers.onPermissionRequest;
|
this.onPermissionRequest = handlers.onPermissionRequest;
|
||||||
this.onActivity = handlers.onActivity;
|
this.onActivity = handlers.onActivity;
|
||||||
this.onReconciliationProgress = handlers.onReconciliationProgress;
|
this.onReconciliationProgress = handlers.onReconciliationProgress;
|
||||||
|
this.onAgentConfigChanged = handlers.onAgentConfigChanged;
|
||||||
this.wsPath = wsPath;
|
this.wsPath = wsPath;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,12 @@ function agentKey(storyId: string, agentName: string): string {
|
|||||||
return `${storyId}:${agentName}`;
|
return `${storyId}:${agentName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentPanel() {
|
interface AgentPanelProps {
|
||||||
|
/** Increment this to trigger a re-fetch of the agent roster. */
|
||||||
|
configVersion?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentPanel({ configVersion = 0 }: AgentPanelProps) {
|
||||||
const { hiddenRosterAgents } = useLozengeFly();
|
const { hiddenRosterAgents } = useLozengeFly();
|
||||||
const [agents, setAgents] = useState<Record<string, AgentState>>({});
|
const [agents, setAgents] = useState<Record<string, AgentState>>({});
|
||||||
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
|
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
|
||||||
@@ -90,13 +95,16 @@ export function AgentPanel() {
|
|||||||
const [editingEditor, setEditingEditor] = useState(false);
|
const [editingEditor, setEditingEditor] = useState(false);
|
||||||
const cleanupRefs = useRef<Record<string, () => void>>({});
|
const cleanupRefs = useRef<Record<string, () => void>>({});
|
||||||
|
|
||||||
// Load roster, existing agents, and editor preference on mount
|
// Re-fetch roster whenever configVersion changes (triggered by agent_config_changed WS event).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
agentsApi
|
agentsApi
|
||||||
.getAgentConfig()
|
.getAgentConfig()
|
||||||
.then(setRoster)
|
.then(setRoster)
|
||||||
.catch((err) => console.error("Failed to load agent config:", err));
|
.catch((err) => console.error("Failed to load agent config:", err));
|
||||||
|
}, [configVersion]);
|
||||||
|
|
||||||
|
// Load existing agents and editor preference on mount
|
||||||
|
useEffect(() => {
|
||||||
agentsApi
|
agentsApi
|
||||||
.listAgents()
|
.listAgents()
|
||||||
.then((agentList) => {
|
.then((agentList) => {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
{ id: string; storyId: string; status: string; message: string }[]
|
{ id: string; storyId: string; status: string; message: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
const reconciliationEventIdRef = useRef(0);
|
const reconciliationEventIdRef = useRef(0);
|
||||||
|
const [agentConfigVersion, setAgentConfigVersion] = useState(0);
|
||||||
|
|
||||||
const wsRef = useRef<ChatWebSocket | null>(null);
|
const wsRef = useRef<ChatWebSocket | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -215,6 +216,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onAgentConfigChanged: () => {
|
||||||
|
setAgentConfigVersion((v) => v + 1);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -829,7 +833,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LozengeFlyProvider pipeline={pipeline}>
|
<LozengeFlyProvider pipeline={pipeline}>
|
||||||
<AgentPanel />
|
<AgentPanel configVersion={agentConfigVersion} />
|
||||||
|
|
||||||
<StagePanel title="To Merge" items={pipeline.merge} />
|
<StagePanel title="To Merge" items={pipeline.merge} />
|
||||||
<StagePanel title="QA" items={pipeline.qa} />
|
<StagePanel title="QA" items={pipeline.qa} />
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ enum WsRequest {
|
|||||||
/// - `update` pushes the updated message history.
|
/// - `update` pushes the updated message history.
|
||||||
/// - `error` reports a request or processing failure.
|
/// - `error` reports a request or processing failure.
|
||||||
/// - `work_item_changed` notifies that a `.story_kit/work/` file changed.
|
/// - `work_item_changed` notifies that a `.story_kit/work/` file changed.
|
||||||
|
/// - `agent_config_changed` notifies that `.story_kit/project.toml` was modified.
|
||||||
enum WsResponse {
|
enum WsResponse {
|
||||||
Token {
|
Token {
|
||||||
content: String,
|
content: String,
|
||||||
@@ -62,13 +63,16 @@ enum WsResponse {
|
|||||||
action: String,
|
action: String,
|
||||||
commit_msg: String,
|
commit_msg: String,
|
||||||
},
|
},
|
||||||
/// Full pipeline state pushed on connect and after every watcher event.
|
/// Full pipeline state pushed on connect and after every work-item watcher event.
|
||||||
PipelineState {
|
PipelineState {
|
||||||
upcoming: Vec<crate::http::workflow::UpcomingStory>,
|
upcoming: Vec<crate::http::workflow::UpcomingStory>,
|
||||||
current: Vec<crate::http::workflow::UpcomingStory>,
|
current: Vec<crate::http::workflow::UpcomingStory>,
|
||||||
qa: Vec<crate::http::workflow::UpcomingStory>,
|
qa: Vec<crate::http::workflow::UpcomingStory>,
|
||||||
merge: Vec<crate::http::workflow::UpcomingStory>,
|
merge: Vec<crate::http::workflow::UpcomingStory>,
|
||||||
},
|
},
|
||||||
|
/// `.story_kit/project.toml` was modified; the frontend should re-fetch the
|
||||||
|
/// agent roster. Does NOT trigger a pipeline state refresh.
|
||||||
|
AgentConfigChanged,
|
||||||
/// Claude Code is requesting user approval before executing a tool.
|
/// Claude Code is requesting user approval before executing a tool.
|
||||||
PermissionRequest {
|
PermissionRequest {
|
||||||
request_id: String,
|
request_id: String,
|
||||||
@@ -89,13 +93,21 @@ enum WsResponse {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<WatcherEvent> for WsResponse {
|
impl From<WatcherEvent> for Option<WsResponse> {
|
||||||
fn from(e: WatcherEvent) -> Self {
|
fn from(e: WatcherEvent) -> Self {
|
||||||
WsResponse::WorkItemChanged {
|
match e {
|
||||||
stage: e.stage,
|
WatcherEvent::WorkItem {
|
||||||
item_id: e.item_id,
|
stage,
|
||||||
action: e.action,
|
item_id,
|
||||||
commit_msg: e.commit_msg,
|
action,
|
||||||
|
commit_msg,
|
||||||
|
} => Some(WsResponse::WorkItemChanged {
|
||||||
|
stage,
|
||||||
|
item_id,
|
||||||
|
action,
|
||||||
|
commit_msg,
|
||||||
|
}),
|
||||||
|
WatcherEvent::ConfigChanged => Some(WsResponse::AgentConfigChanged),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +150,8 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to filesystem watcher events and forward them to the client.
|
// Subscribe to filesystem watcher events and forward them to the client.
|
||||||
// After each watcher event, also push the updated pipeline state.
|
// After each work-item event, also push the updated pipeline state.
|
||||||
|
// Config-changed events are forwarded as-is without a pipeline refresh.
|
||||||
let tx_watcher = tx.clone();
|
let tx_watcher = tx.clone();
|
||||||
let ctx_watcher = ctx.clone();
|
let ctx_watcher = ctx.clone();
|
||||||
let mut watcher_rx = ctx.watcher_tx.subscribe();
|
let mut watcher_rx = ctx.watcher_tx.subscribe();
|
||||||
@@ -146,11 +159,16 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
|||||||
loop {
|
loop {
|
||||||
match watcher_rx.recv().await {
|
match watcher_rx.recv().await {
|
||||||
Ok(evt) => {
|
Ok(evt) => {
|
||||||
if tx_watcher.send(evt.into()).is_err() {
|
let is_work_item =
|
||||||
|
matches!(evt, crate::io::watcher::WatcherEvent::WorkItem { .. });
|
||||||
|
let ws_msg: Option<WsResponse> = evt.into();
|
||||||
|
if let Some(msg) = ws_msg && tx_watcher.send(msg).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Push refreshed pipeline state after the change.
|
// Only push refreshed pipeline state after work-item changes,
|
||||||
if let Ok(state) = load_pipeline_state(ctx_watcher.as_ref())
|
// not after config-file changes.
|
||||||
|
if is_work_item
|
||||||
|
&& let Ok(state) = load_pipeline_state(ctx_watcher.as_ref())
|
||||||
&& tx_watcher.send(state.into()).is_err()
|
&& tx_watcher.send(state.into()).is_err()
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
//! Filesystem watcher for `.story_kit/work/`.
|
//! Filesystem watcher for `.story_kit/work/` and `.story_kit/project.toml`.
|
||||||
//!
|
//!
|
||||||
//! Watches the work pipeline directories for file changes, infers the lifecycle
|
//! Watches the work pipeline directories for file changes, infers the lifecycle
|
||||||
//! stage from the target directory name, auto-commits with a deterministic message,
|
//! stage from the target directory name, auto-commits with a deterministic message,
|
||||||
//! and broadcasts a [`WatcherEvent`] to all connected WebSocket clients.
|
//! and broadcasts a [`WatcherEvent`] to all connected WebSocket clients.
|
||||||
//!
|
//!
|
||||||
|
//! Also watches `.story_kit/project.toml` for modifications and broadcasts
|
||||||
|
//! [`WatcherEvent::ConfigChanged`] so the frontend can reload the agent roster
|
||||||
|
//! without a server restart.
|
||||||
|
//!
|
||||||
//! # Debouncing
|
//! # Debouncing
|
||||||
//! Events are buffered for 300 ms after the last activity. All changes within the
|
//! Events are buffered for 300 ms after the last activity. All changes within the
|
||||||
//! window are batched into a single `git add + commit`. This avoids double-commits
|
//! window are batched into a single `git add + commit`. This avoids double-commits
|
||||||
@@ -24,17 +28,37 @@ use std::sync::mpsc;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
/// A lifecycle event emitted by the filesystem watcher after auto-committing.
|
/// A lifecycle event emitted by the filesystem watcher.
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct WatcherEvent {
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum WatcherEvent {
|
||||||
|
/// A work-pipeline file was created, modified, or deleted.
|
||||||
|
WorkItem {
|
||||||
/// Pipeline stage directory (e.g. `"2_current"`, `"5_archived"`).
|
/// Pipeline stage directory (e.g. `"2_current"`, `"5_archived"`).
|
||||||
pub stage: String,
|
stage: String,
|
||||||
/// Work item ID (filename stem without extension, e.g. `"42_story_my_feature"`).
|
/// Work item ID (filename stem without extension, e.g. `"42_story_my_feature"`).
|
||||||
pub item_id: String,
|
item_id: String,
|
||||||
/// Semantic action inferred from the stage (e.g. `"start"`, `"accept"`).
|
/// Semantic action inferred from the stage (e.g. `"start"`, `"accept"`).
|
||||||
pub action: String,
|
action: String,
|
||||||
/// The deterministic git commit message used (or that would have been used).
|
/// The deterministic git commit message used (or that would have been used).
|
||||||
pub commit_msg: String,
|
commit_msg: String,
|
||||||
|
},
|
||||||
|
/// `.story_kit/project.toml` was modified at the project root (not inside a worktree).
|
||||||
|
ConfigChanged,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return `true` if `path` is the root-level `.story_kit/project.toml`, i.e.
|
||||||
|
/// `{git_root}/.story_kit/project.toml`.
|
||||||
|
///
|
||||||
|
/// Returns `false` for paths inside worktree directories (paths containing
|
||||||
|
/// a `worktrees` component).
|
||||||
|
pub fn is_config_file(path: &Path, git_root: &Path) -> bool {
|
||||||
|
// Reject any path that passes through the worktrees directory.
|
||||||
|
if path.components().any(|c| c.as_os_str() == "worktrees") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let expected = git_root.join(".story_kit").join("project.toml");
|
||||||
|
path == expected
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a pipeline directory name to a (action, commit-message-prefix) pair.
|
/// Map a pipeline directory name to a (action, commit-message-prefix) pair.
|
||||||
@@ -161,7 +185,7 @@ fn flush_pending(
|
|||||||
slog!("[watcher] skipped (already committed): {commit_msg}");
|
slog!("[watcher] skipped (already committed): {commit_msg}");
|
||||||
}
|
}
|
||||||
let stage = additions.first().map_or("unknown", |(_, s)| s);
|
let stage = additions.first().map_or("unknown", |(_, s)| s);
|
||||||
let evt = WatcherEvent {
|
let evt = WatcherEvent::WorkItem {
|
||||||
stage: stage.to_string(),
|
stage: stage.to_string(),
|
||||||
item_id,
|
item_id,
|
||||||
action: action.to_string(),
|
action: action.to_string(),
|
||||||
@@ -178,7 +202,8 @@ fn flush_pending(
|
|||||||
/// Start the filesystem watcher on a dedicated OS thread.
|
/// Start the filesystem watcher on a dedicated OS thread.
|
||||||
///
|
///
|
||||||
/// `work_dir` — absolute path to `.story_kit/work/` (watched recursively).
|
/// `work_dir` — absolute path to `.story_kit/work/` (watched recursively).
|
||||||
/// `git_root` — project root (passed to `git` commands as cwd).
|
/// `git_root` — project root (passed to `git` commands as cwd, and used to
|
||||||
|
/// derive the config file path `.story_kit/project.toml`).
|
||||||
/// `event_tx` — broadcast sender; each connected WebSocket client holds a receiver.
|
/// `event_tx` — broadcast sender; each connected WebSocket client holds a receiver.
|
||||||
pub fn start_watcher(
|
pub fn start_watcher(
|
||||||
work_dir: PathBuf,
|
work_dir: PathBuf,
|
||||||
@@ -203,12 +228,22 @@ pub fn start_watcher(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also watch .story_kit/project.toml for hot-reload of agent config.
|
||||||
|
let config_file = git_root.join(".story_kit").join("project.toml");
|
||||||
|
if config_file.exists()
|
||||||
|
&& let Err(e) = watcher.watch(&config_file, RecursiveMode::NonRecursive)
|
||||||
|
{
|
||||||
|
slog!("[watcher] failed to watch config file {}: {e}", config_file.display());
|
||||||
|
}
|
||||||
|
|
||||||
slog!("[watcher] watching {}", work_dir.display());
|
slog!("[watcher] watching {}", work_dir.display());
|
||||||
|
|
||||||
const DEBOUNCE: Duration = Duration::from_millis(300);
|
const DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
// Map path → stage for pending (uncommitted) changes.
|
// Map path → stage for pending (uncommitted) work-item changes.
|
||||||
let mut pending: HashMap<PathBuf, String> = HashMap::new();
|
let mut pending: HashMap<PathBuf, String> = HashMap::new();
|
||||||
|
// Whether a config file change is pending in the current debounce window.
|
||||||
|
let mut config_changed_pending = false;
|
||||||
let mut deadline: Option<Instant> = None;
|
let mut deadline: Option<Instant> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -229,7 +264,11 @@ pub fn start_watcher(
|
|||||||
|
|
||||||
if is_relevant_kind {
|
if is_relevant_kind {
|
||||||
for path in event.paths {
|
for path in event.paths {
|
||||||
if let Some(stage) = stage_for_path(&path) {
|
if is_config_file(&path, &git_root) {
|
||||||
|
slog!("[watcher] config change detected: {}", path.display());
|
||||||
|
config_changed_pending = true;
|
||||||
|
deadline = Some(Instant::now() + DEBOUNCE);
|
||||||
|
} else if let Some(stage) = stage_for_path(&path) {
|
||||||
pending.insert(path, stage);
|
pending.insert(path, stage);
|
||||||
deadline = Some(Instant::now() + DEBOUNCE);
|
deadline = Some(Instant::now() + DEBOUNCE);
|
||||||
}
|
}
|
||||||
@@ -249,9 +288,16 @@ pub fn start_watcher(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if flush && !pending.is_empty() {
|
if flush {
|
||||||
|
if !pending.is_empty() {
|
||||||
flush_pending(&pending, &git_root, &event_tx);
|
flush_pending(&pending, &git_root, &event_tx);
|
||||||
pending.clear();
|
pending.clear();
|
||||||
|
}
|
||||||
|
if config_changed_pending {
|
||||||
|
slog!("[watcher] broadcasting agent_config_changed");
|
||||||
|
let _ = event_tx.send(WatcherEvent::ConfigChanged);
|
||||||
|
config_changed_pending = false;
|
||||||
|
}
|
||||||
deadline = None;
|
deadline = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,4 +363,42 @@ mod tests {
|
|||||||
|
|
||||||
assert!(stage_metadata("unknown", "id").is_none());
|
assert!(stage_metadata("unknown", "id").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_config_file_identifies_root_project_toml() {
|
||||||
|
let git_root = PathBuf::from("/proj");
|
||||||
|
let config = git_root.join(".story_kit").join("project.toml");
|
||||||
|
assert!(is_config_file(&config, &git_root));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_config_file_rejects_worktree_copies() {
|
||||||
|
let git_root = PathBuf::from("/proj");
|
||||||
|
// project.toml inside a worktree must NOT be treated as the root config.
|
||||||
|
let worktree_config = PathBuf::from(
|
||||||
|
"/proj/.story_kit/worktrees/42_story_foo/.story_kit/project.toml",
|
||||||
|
);
|
||||||
|
assert!(!is_config_file(&worktree_config, &git_root));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_config_file_rejects_other_files() {
|
||||||
|
let git_root = PathBuf::from("/proj");
|
||||||
|
// Random files must not match.
|
||||||
|
assert!(!is_config_file(
|
||||||
|
&PathBuf::from("/proj/.story_kit/work/2_current/42_story_foo.md"),
|
||||||
|
&git_root
|
||||||
|
));
|
||||||
|
assert!(!is_config_file(
|
||||||
|
&PathBuf::from("/proj/.story_kit/README.md"),
|
||||||
|
&git_root
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_config_file_rejects_wrong_root() {
|
||||||
|
let git_root = PathBuf::from("/proj");
|
||||||
|
let other_root_config = PathBuf::from("/other/.story_kit/project.toml");
|
||||||
|
assert!(!is_config_file(&other_root_config, &git_root));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user