1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
use crate::daemon_id::DaemonId;
use crate::ipc::client::IpcClient;
use crate::pitchfork_toml::{PitchforkToml, PitchforkTomlAuto};
use crate::ui::table::print_table;
use crate::{Result, env};
use miette::IntoDiagnostic;
use std::collections::HashSet;
use std::path::PathBuf;
use comfy_table::{Cell, Color, ContentArrangement, Table};
/// Project session management for IDE and workspace integrations.
#[derive(Debug, usage_rs::Args)]
#[usage(verbatim_doc_comment)]
pub struct Project {
#[usage(subcommand)]
command: ProjectCommands,
}
#[derive(Debug, usage_rs::Subcommands)]
enum ProjectCommands {
Enter(Enter),
Leave(Leave),
List(List),
}
/// Enter (or replace) a project session tied to a host process.
///
/// The host PID is required and is used by the supervisor to revoke the
/// session automatically when the process dies (with a title match to guard
/// against PID reuse).
///
/// On Windows, automatic revocation when the host process exits is not
/// available (Git Bash PIDs are invisible to the process table); sessions
/// must be ended with an explicit `project leave`.
#[derive(Debug, usage_rs::Args)]
pub struct Enter {
/// Host process PID that owns the session. Required.
#[usage(long)]
pid: u32,
/// Project directory to associate with the session. Defaults to the
/// current working directory and is canonicalized before tracking.
#[usage(long)]
directory: Option<PathBuf>,
}
/// Leave a project session and evaluate its directory for autostop.
#[derive(Debug, usage_rs::Args)]
pub struct Leave {
/// Host process PID that owns the session.
#[usage(long)]
pid: u32,
/// Project directory the session was entered with. Defaults to the
/// current working directory and is canonicalized before lookup. Must
/// match the directory used at enter time.
#[usage(long)]
directory: Option<PathBuf>,
}
/// List tracked project sessions.
#[derive(Debug, usage_rs::Args)]
#[usage(
verbatim_doc_comment,
long_about = "\
List tracked project sessions
Displays a table of active project sessions with their host PID, directory,
liveness status, and recorded process title.
Example:
pitchfork project list
pitchfork project list --json"
)]
pub struct List {
/// Output in JSON format
#[usage(long)]
json: bool,
}
impl Project {
pub async fn run(&self) -> Result<()> {
match &self.command {
ProjectCommands::Enter(enter) => enter.run().await,
ProjectCommands::Leave(leave) => leave.run().await,
ProjectCommands::List(list) => list.run().await,
}
}
}
/// Resolve a directory argument (or the CWD) to a canonical absolute path.
///
/// `dunce::simplified` strips the Windows verbatim (`\\?\`) prefix that
/// `canonicalize()` adds, so stored paths and JSON output stay user-friendly.
/// The non-canonical form is safe because `is_within` in autostop.rs falls
/// back to canonicalizing both sides when a plain `starts_with` fails.
fn resolve_directory(directory: &Option<PathBuf>) -> Result<PathBuf> {
let canonical = match directory {
Some(d) => d.canonicalize().into_diagnostic()?,
None => env::CWD.canonicalize().into_diagnostic()?,
};
Ok(dunce::simplified(&canonical).to_path_buf())
}
impl Enter {
pub async fn run(&self) -> Result<()> {
let target_dir = resolve_directory(&self.directory)?;
// Validate configuration before creating a session so invalid configs
// do not leave a half-entered session behind.
let pt = PitchforkToml::all_merged_from(&target_dir)?;
let ipc = IpcClient::connect(true).await?;
ipc.project_enter(self.pid, target_dir.clone()).await?;
let to_start: Vec<DaemonId> = pt
.daemons
.iter()
.filter(|(_, d)| d.auto.contains(&PitchforkTomlAuto::Start))
.map(|(id, _)| id.clone())
.collect();
if !to_start.is_empty() {
let active_daemons: HashSet<DaemonId> = ipc
.active_daemons()
.await?
.into_iter()
.map(|d| d.id)
.collect();
let mut args = vec!["start".to_string(), "--on-directory-enter".to_string()];
// See the same loop in `cd`: the flag above is what drops a
// completed task reached as a dependency; this only avoids
// spawning `start` when every requested daemon is already settled.
let completed = crate::daemon_list::completed_oneshots();
for id in &to_start {
let settled =
completed.contains(id) && pt.daemons.get(id).is_some_and(|d| d.is_oneshot());
if active_daemons.contains(id) || settled {
continue;
}
args.push(id.qualified());
}
if args.len() > 2 {
let status = tokio::process::Command::new(&*env::PITCHFORK_BIN)
.args(&args)
.current_dir(&target_dir)
.status()
.await
.into_diagnostic()?;
if !status.success() {
// Roll back the session so a failed start doesn't leave an
// orphaned entry that would affect autostop on the next leave.
let _ = ipc.project_leave(self.pid, target_dir).await;
miette::bail!(
"pitchfork start {} exited with {status}",
args[1..].join(" ")
);
}
}
}
super::drain_notifications(&ipc).await;
Ok(())
}
}
impl Leave {
pub async fn run(&self) -> Result<()> {
let target_dir = resolve_directory(&self.directory)?;
let ipc = IpcClient::connect(true).await?;
ipc.project_leave(self.pid, target_dir).await?;
super::drain_notifications(&ipc).await;
Ok(())
}
}
impl List {
pub async fn run(&self) -> Result<()> {
let ipc = IpcClient::connect(true).await?;
let mut sessions = ipc.get_project_sessions().await?;
// Stable ordering: by PID, then directory.
sessions.sort_by(|a, b| a.pid.cmp(&b.pid).then(a.directory.cmp(&b.directory)));
if self.json {
return crate::cli::json_output::print_json(&sessions);
}
let mut table = Table::new();
table
.load_style(comfy_table::presets::NOTHING)
.set_content_arrangement(ContentArrangement::Disabled);
if console::user_attended() {
table.set_header(vec!["PID", "DIRECTORY", "STATUS", "TITLE"]);
}
for s in &sessions {
let pid = s.pid.to_string();
let directory = s.directory.display().to_string();
let (status_text, status_color) = if s.alive {
("alive".to_string(), Color::Green)
} else {
("dead".to_string(), Color::Red)
};
// Prefer the recorded title for display; fall back to the current
// title if no snapshot was taken (shouldn't normally happen since
// enter always records one for a live PID).
let title = s
.liveness_title
.clone()
.or_else(|| s.current_title.clone())
.unwrap_or_default();
table.add_row(vec![
Cell::new(&pid),
Cell::new(&directory),
Cell::new(&status_text).fg(status_color),
Cell::new(&title),
]);
}
print_table(table)
}
}