Skip to content

Commit 263e320

Browse files
troyjr4103claude
andcommitted
feat(kin): VFS Phase 2 — projection-aware endpoints, write-back, snapshot/eject
Phase 2a: In-memory project_to_bytes() + project_overlay_to_bytes() - Pure functions, no disk I/O, 11 tests Phase 2c: FileLayout cache (ProjectionState) in DaemonState Phase 2d: Projection-aware VFS endpoints - /vfs/read now checks overlay for entity mutations → projects if needed - /vfs/tree merges overlay additions - POST /vfs/file-changed triggers reconciliation (write-back) - GET /vfs/subscribe SSE stub for push invalidation Phase 2i: Pre-init snapshot + kin eject - kin init snapshots repo to .kin/snapshot/ (hardlinks) - kin eject restores from snapshot, removes .kin/ - 7 tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 34c1724 commit 263e320

10 files changed

Lines changed: 904 additions & 8 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Firelock, LLC
3+
4+
use anyhow::{bail, Result};
5+
use dialoguer::Confirm;
6+
use kin_core::KinLayout;
7+
use std::fs;
8+
use std::path::Path;
9+
10+
/// Restore the project to its pre-Kin state using the snapshot taken during `kin init`.
11+
pub async fn run(force: bool) -> Result<()> {
12+
let cwd = std::env::current_dir()?;
13+
let layout = KinLayout::discover(&cwd)
14+
.ok_or_else(|| anyhow::anyhow!("not a Kin repository"))?;
15+
16+
let snapshot_dir = layout.root().join("snapshot");
17+
if !snapshot_dir.exists() {
18+
bail!("No snapshot found. Cannot eject.");
19+
}
20+
21+
let manifest_path = snapshot_dir.join("manifest.json");
22+
let manifest: serde_json::Value = if manifest_path.exists() {
23+
serde_json::from_str(&fs::read_to_string(&manifest_path)?)?
24+
} else {
25+
bail!("Snapshot manifest missing. Cannot verify restore.");
26+
};
27+
28+
let file_count = manifest["file_count"].as_u64().unwrap_or(0);
29+
30+
// Confirm with the user unless --force.
31+
if !force {
32+
println!("This will:");
33+
println!(" - Stop kin-daemon and kin-vfs-daemon");
34+
println!(" - Restore {} files from snapshot", file_count);
35+
println!(" - Remove the .kin/ directory entirely");
36+
println!();
37+
38+
let confirmed = Confirm::new()
39+
.with_prompt("Continue?")
40+
.default(false)
41+
.interact()?;
42+
43+
if !confirmed {
44+
println!("Aborted.");
45+
return Ok(());
46+
}
47+
}
48+
49+
// Best-effort daemon shutdown.
50+
stop_daemons(layout.root());
51+
52+
// Restore files from snapshot to the project root.
53+
let working_dir = layout.working_dir().to_path_buf();
54+
let mut restored: u64 = 0;
55+
restore_files(&snapshot_dir, &snapshot_dir, &working_dir, &mut restored)?;
56+
57+
// Remove .kin/ entirely.
58+
let kin_dir = layout.root().to_path_buf();
59+
fs::remove_dir_all(&kin_dir)?;
60+
61+
println!(
62+
"Kin removed. Your files are restored to pre-init state ({} files).",
63+
restored
64+
);
65+
Ok(())
66+
}
67+
68+
/// Walk the snapshot directory and copy each file back to the project root.
69+
fn restore_files(
70+
snapshot_root: &Path,
71+
current: &Path,
72+
working_dir: &Path,
73+
restored: &mut u64,
74+
) -> Result<()> {
75+
for entry in fs::read_dir(current)? {
76+
let entry = entry?;
77+
let path = entry.path();
78+
79+
// Skip the manifest itself — it's metadata, not a user file.
80+
if path.file_name().map(|f| f == "manifest.json").unwrap_or(false)
81+
&& path.parent() == Some(snapshot_root)
82+
{
83+
continue;
84+
}
85+
86+
let ft = entry.file_type()?;
87+
if ft.is_dir() {
88+
restore_files(snapshot_root, &path, working_dir, restored)?;
89+
} else if ft.is_file() {
90+
let rel = path.strip_prefix(snapshot_root)?;
91+
let dest = working_dir.join(rel);
92+
if let Some(parent) = dest.parent() {
93+
fs::create_dir_all(parent)?;
94+
}
95+
fs::copy(&path, &dest)?;
96+
*restored += 1;
97+
}
98+
}
99+
Ok(())
100+
}
101+
102+
/// Best-effort attempt to stop running Kin daemons.
103+
fn stop_daemons(kin_root: &Path) {
104+
// Try PID file for kin-daemon.
105+
kill_pid_file(&kin_root.join("daemon.pid"));
106+
// Try PID file for kin-vfs-daemon.
107+
kill_pid_file(&kin_root.join("vfs.pid"));
108+
}
109+
110+
fn kill_pid_file(path: &Path) {
111+
if let Ok(content) = fs::read_to_string(path) {
112+
if let Ok(pid) = content.trim().parse::<u32>() {
113+
// SIGTERM via `kill` command — best effort, ignore errors.
114+
let _ = std::process::Command::new("kill")
115+
.args(["-TERM", &pid.to_string()])
116+
.output();
117+
}
118+
}
119+
}
120+
121+
#[cfg(test)]
122+
mod tests {
123+
use super::*;
124+
use std::fs;
125+
126+
/// Helper: set up a fake Kin repo with a snapshot.
127+
fn setup_fake_repo(dir: &Path) {
128+
let kin = dir.join(".kin");
129+
let snapshot = kin.join("snapshot");
130+
fs::create_dir_all(&snapshot).unwrap();
131+
fs::create_dir_all(snapshot.join("src")).unwrap();
132+
133+
// Snapshot files.
134+
fs::write(snapshot.join("README.md"), "hello").unwrap();
135+
fs::write(snapshot.join("src/main.rs"), "fn main() {}").unwrap();
136+
137+
// Manifest.
138+
let manifest = serde_json::json!({
139+
"timestamp": "2026-03-23T00:00:00Z",
140+
"file_count": 2,
141+
"total_bytes": 17,
142+
"git_head": null,
143+
});
144+
fs::write(
145+
snapshot.join("manifest.json"),
146+
serde_json::to_string_pretty(&manifest).unwrap(),
147+
)
148+
.unwrap();
149+
150+
// Some Kin-internal files that should be removed on eject.
151+
fs::write(kin.join("config.toml"), "[core]").unwrap();
152+
}
153+
154+
#[test]
155+
fn eject_restores_files() {
156+
let dir = tempfile::tempdir().unwrap();
157+
let root = dir.path();
158+
setup_fake_repo(root);
159+
160+
let snapshot_dir = root.join(".kin/snapshot");
161+
let working_dir = root.to_path_buf();
162+
let mut restored = 0u64;
163+
restore_files(&snapshot_dir, &snapshot_dir, &working_dir, &mut restored).unwrap();
164+
165+
assert_eq!(restored, 2);
166+
assert_eq!(fs::read_to_string(root.join("README.md")).unwrap(), "hello");
167+
assert_eq!(
168+
fs::read_to_string(root.join("src/main.rs")).unwrap(),
169+
"fn main() {}"
170+
);
171+
}
172+
173+
#[test]
174+
fn eject_removes_kin_dir() {
175+
let dir = tempfile::tempdir().unwrap();
176+
let root = dir.path();
177+
setup_fake_repo(root);
178+
179+
// Restore files first.
180+
let snapshot_dir = root.join(".kin/snapshot");
181+
let working_dir = root.to_path_buf();
182+
let mut restored = 0u64;
183+
restore_files(&snapshot_dir, &snapshot_dir, &working_dir, &mut restored).unwrap();
184+
185+
// Then remove .kin/.
186+
fs::remove_dir_all(root.join(".kin")).unwrap();
187+
188+
assert!(!root.join(".kin").exists());
189+
// But restored files should still be there.
190+
assert!(root.join("README.md").exists());
191+
assert!(root.join("src/main.rs").exists());
192+
}
193+
194+
#[test]
195+
fn eject_manifest_skip() {
196+
let dir = tempfile::tempdir().unwrap();
197+
let root = dir.path();
198+
setup_fake_repo(root);
199+
200+
let snapshot_dir = root.join(".kin/snapshot");
201+
let working_dir = root.to_path_buf();
202+
let mut restored = 0u64;
203+
restore_files(&snapshot_dir, &snapshot_dir, &working_dir, &mut restored).unwrap();
204+
205+
// manifest.json should NOT be restored to the project root.
206+
assert!(!root.join("manifest.json").exists());
207+
}
208+
}

0 commit comments

Comments
 (0)