agentstatedeveloper_cli/commands/
init.rs1use std::fs;
10#[cfg(unix)]
14use std::os::unix::fs::PermissionsExt;
15use std::path::{Path, PathBuf};
16
17use anyhow::{Context, Result};
18use clap::Args;
19use serde_json::json;
20
21use agentstatedeveloper_core::{ASD_SCHEMA_VERSION, Engine, paths};
22use agentstategraph::CommitOptions;
23use agentstategraph_core::IntentCategory;
24
25use crate::config::Config;
26
27struct HookDef {
32 filename: &'static str,
33 trigger: &'static str,
34 command: &'static str,
35 purpose: &'static str,
36 script: &'static str,
37}
38
39const HOOKS: &[HookDef] = &[
40 HookDef {
41 filename: "pre-commit",
42 trigger: "git commit",
43 command: "asd conclusions export",
44 purpose: "write committed conclusions (decisions/hazards/recipes/…) to .asd/conclusions/*.jsonl",
45 script: "#!/usr/bin/env sh
46# ASD pre-commit hook — installed by `asd init`
47# Plan B: write the compact, byte-stable conclusion JSONL files to
48# .asd/conclusions/. These files travel with the commit (kilobytes, not MB)
49# and carry the expensive LLM-formed conclusions a fresh clone needs.
50# The big derived cache lives at .asd/cache/ and is gitignored.
51set -e
52asd conclusions export --quiet
53git add .asd/conclusions/ 2>/dev/null || true
54",
55 },
56 HookDef {
57 filename: "post-merge",
58 trigger: "git merge / git pull",
59 command: "asd conclusions import && asd index .",
60 purpose: "import committed .asd/conclusions/ into local ledger and rebuild index",
61 script: "#!/usr/bin/env sh
62# ASD post-merge hook — installed by `asd init`
63# Plan B: pull any new conclusion entries from the merged branch into
64# the local ASG database, then rebuild the derived semantic index.
65set -e
66asd conclusions import 2>/dev/null || true
67asd index .
68",
69 },
70 HookDef {
71 filename: "post-checkout",
72 trigger: "git checkout / git switch",
73 command: "asd conclusions import && asd index .",
74 purpose: "sync local ledger to the checked-out branch's conclusions",
75 script: "#!/usr/bin/env sh
76# ASD post-checkout hook — installed by `asd init`
77# Plan B: align the local ledger with the checked-out branch's committed
78# conclusions, then rebuild the derived semantic index.
79# $3 is 1 for branch checkout, 0 for file checkout — only run on branch.
80[ \"$3\" = \"1\" ] || exit 0
81set -e
82asd conclusions import 2>/dev/null || true
83asd index .
84",
85 },
86 HookDef {
87 filename: "post-commit",
88 trigger: "git commit",
89 command: "asd annotate-commit --write HEAD && asd index .",
90 purpose: "attach semantic residue from commit message to touched symbols; rebuild index",
91 script: "#!/usr/bin/env sh
92# ASD post-commit hook — installed by `asd init`
93# Attaches semantic residue from the commit message to touched symbols
94# (decisions, invariants, hazards, proofs, validation outcomes), then
95# rebuilds the FTS index so queries immediately reflect the new state.
96SHA=$(git rev-parse HEAD 2>/dev/null)
97if [ -n \"$SHA\" ]; then
98 asd annotate-commit --write \"$SHA\" 2>/dev/null || true
99fi
100asd index . 2>/dev/null || true
101",
102 },
103];
104
105#[derive(Debug, Args)]
110pub struct InitArgs {
111 #[arg(long, default_value_t = false)]
114 pub no_hooks: bool,
115}
116
117pub fn run(cfg: &Config, args: InitArgs) -> Result<()> {
122 if let Some(parent) = cfg.db_path.parent() {
124 if !parent.as_os_str().is_empty() {
125 fs::create_dir_all(parent).ok();
126 }
127 }
128
129 let engine = Engine::open_sqlite(&cfg.db_path)?;
130
131 let path = paths::schema_version_path();
132 let value = json!(ASD_SCHEMA_VERSION);
133 let opts = CommitOptions::new(
134 &cfg.agent_id,
135 IntentCategory::Checkpoint,
136 format!("stamp asd schema-version {}", ASD_SCHEMA_VERSION),
137 );
138 engine
139 .repo
140 .set_json(&engine.ref_name, &path, &value, opts)?;
141
142 println!("initialized at {}", cfg.db_path.display());
143
144 let project_root = cfg
146 .db_path
147 .parent()
148 .filter(|p| !p.as_os_str().is_empty())
149 .map(|p| p.to_path_buf())
150 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
151
152 update_gitignore(&project_root)?;
154
155 update_gitattributes(&project_root)?;
158
159 scaffold_sidecar_dirs(&project_root)?;
163
164 if args.no_hooks {
166 println!("\ngit hooks: skipped (--no-hooks). Re-run `asd init` to install.");
167 } else {
168 install_hooks(&project_root)?;
169 }
170
171 Ok(())
172}
173
174fn update_gitignore(root: &Path) -> Result<()> {
179 let gi_path = root.join(".gitignore");
180 let existing = if gi_path.exists() {
181 fs::read_to_string(&gi_path).unwrap_or_default()
182 } else {
183 String::new()
184 };
185
186 let mut lines: Vec<String> = existing.lines().map(|l| l.to_string()).collect();
187 let mut changed = false;
188
189 let before = lines.len();
193 lines.retain(|l| {
194 let t = l.trim();
195 t != ".asd/" && t != ".asd"
196 });
197 if lines.len() != before {
198 changed = true;
199 }
200
201 if !lines.iter().any(|l| l.trim() == ".asd-state.db") {
203 lines.push(".asd-state.db".to_string());
204 changed = true;
205 }
206
207 if !lines.iter().any(|l| l.trim() == ".asd/v1/") {
209 lines.push(".asd/v1/".to_string());
210 changed = true;
211 }
212
213 if !lines.iter().any(|l| l.trim() == ".asd/cache/") {
217 lines.push(".asd/cache/".to_string());
218 changed = true;
219 }
220
221 if changed {
222 let content = lines.join("\n") + "\n";
223 fs::write(&gi_path, content)
224 .with_context(|| format!("failed to write {}", gi_path.display()))?;
225 println!(
226 ".gitignore: updated (.asd-state.db and .asd/v1/ ignored — both are local derived state)"
227 );
228 }
229
230 let sidecar_tracked = std::process::Command::new("git")
234 .args(["ls-files", "--error-unmatch", ".asd/v1"])
235 .current_dir(root)
236 .stdout(std::process::Stdio::null())
237 .stderr(std::process::Stdio::null())
238 .status()
239 .map(|s| s.success())
240 .unwrap_or(false);
241 if sidecar_tracked {
242 println!();
243 println!(" NOTE: .asd/v1/ is currently tracked in git from a prior install.");
244 println!(" To untrack it without deleting your local copy, run:");
245 println!(" git rm -r --cached .asd/v1");
246 println!(" git commit -m 'stop tracking .asd/v1/ sidecar (regenerable cache)'");
247 }
248
249 Ok(())
250}
251
252const CONCLUSIONS_MERGE_ATTR: &str = ".asd/conclusions/*.jsonl merge=asdconclusions";
261
262fn update_gitattributes(root: &Path) -> Result<()> {
263 let ga_path = root.join(".gitattributes");
264 let existing = if ga_path.exists() {
265 fs::read_to_string(&ga_path).unwrap_or_default()
266 } else {
267 String::new()
268 };
269 if existing.lines().any(|l| l.trim() == CONCLUSIONS_MERGE_ATTR) {
270 return Ok(());
271 }
272 let mut content = existing;
273 if !content.is_empty() && !content.ends_with('\n') {
274 content.push('\n');
275 }
276 content.push_str(CONCLUSIONS_MERGE_ATTR);
277 content.push('\n');
278 fs::write(&ga_path, content)
279 .with_context(|| format!("failed to write {}", ga_path.display()))?;
280 println!(".gitattributes: conclusions JSONL bound to the `asdconclusions` union merge driver");
281 Ok(())
282}
283
284fn scaffold_sidecar_dirs(root: &Path) -> Result<()> {
291 let conclusions = root.join(".asd/conclusions");
292 let cache = root.join(".asd/cache");
293 fs::create_dir_all(&conclusions).ok();
294 fs::create_dir_all(&cache).ok();
295
296 let readme = conclusions.join("README.md");
297 if !readme.exists() {
298 let body = "# .asd/conclusions/\n\n\
299Compact JSONL home for ASD's six conclusion classes: decisions,\n\
300classifications, mappings, hazards, recipes, followups.\n\n\
301Files here travel with the git repo. They are written by\n\
302`asd conclusions export` (pre-commit) and read back by\n\
303`asd conclusions import` (post-merge / post-checkout).\n\n\
304Target size: kilobytes per project, not megabytes. The big derived\n\
305cache (call graph, effects-rev, symbol blobs) lives at `.asd/cache/`\n\
306which is gitignored.\n";
307 let _ = fs::write(&readme, body);
308 }
309 Ok(())
310}
311
312fn install_hooks(root: &Path) -> Result<()> {
317 let hooks_dir = root.join(".asd/hooks");
318 fs::create_dir_all(&hooks_dir)
319 .with_context(|| format!("failed to create {}", hooks_dir.display()))?;
320
321 let mut installed: Vec<&HookDef> = Vec::new();
322
323 for hook in HOOKS {
324 let path = hooks_dir.join(hook.filename);
325 fs::write(&path, hook.script)
326 .with_context(|| format!("failed to write hook {}", path.display()))?;
327 #[cfg(unix)]
331 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
332 .with_context(|| format!("failed to chmod {}", path.display()))?;
333 installed.push(hook);
334 }
335
336 let status = std::process::Command::new("git")
338 .args(["config", "core.hooksPath", ".asd/hooks"])
339 .current_dir(root)
340 .status();
341
342 let hooks_path_set = matches!(status, Ok(s) if s.success());
343
344 let driver_set = register_merge_driver(root);
349
350 println!("\nASD git hooks installed (.asd/hooks/):\n");
352 for hook in &installed {
353 println!(" {:<16} trigger: {}", hook.filename, hook.trigger);
354 println!(" {:<16} command: {}", "", hook.command);
355 println!(" {:<16} purpose: {}", "", hook.purpose);
356 println!();
357 }
358
359 if hooks_path_set {
360 println!(" core.hooksPath → .asd/hooks (hooks are now active)");
361 } else {
362 println!(" WARNING: could not set core.hooksPath automatically.");
363 println!(" Run manually: git config core.hooksPath .asd/hooks");
364 }
365
366 if driver_set {
367 println!(" merge.asdconclusions → asd conclusions merge (sidecar auto-unions on merge)");
368 } else {
369 println!(" WARNING: could not register the conclusions merge driver automatically.");
370 println!(" Run manually:");
371 println!(" git config merge.asdconclusions.name 'ASD conclusions union merge'");
372 println!(" git config merge.asdconclusions.driver 'asd conclusions merge %O %A %B'");
373 }
374
375 println!("\n To skip hook installation: asd init --no-hooks");
376 println!(" To review hooks later: asd hooks");
377
378 Ok(())
379}
380
381fn register_merge_driver(root: &Path) -> bool {
384 let set = |key: &str, val: &str| -> bool {
385 std::process::Command::new("git")
386 .args(["config", key, val])
387 .current_dir(root)
388 .status()
389 .map(|s| s.success())
390 .unwrap_or(false)
391 };
392 let name_ok = set("merge.asdconclusions.name", "ASD conclusions union merge");
393 let driver_ok = set(
394 "merge.asdconclusions.driver",
395 "asd conclusions merge %O %A %B",
396 );
397 name_ok && driver_ok
398}
399
400pub struct HookStatus {
405 pub filename: &'static str,
406 pub trigger: &'static str,
407 pub command: &'static str,
408 pub purpose: &'static str,
409 pub installed: bool,
410}
411
412pub fn hook_statuses(root: &Path) -> Vec<HookStatus> {
413 HOOKS
414 .iter()
415 .map(|h| HookStatus {
416 filename: h.filename,
417 trigger: h.trigger,
418 command: h.command,
419 purpose: h.purpose,
420 installed: root.join(".asd/hooks").join(h.filename).exists(),
421 })
422 .collect()
423}
424
425pub fn hooks_path_is_set(root: &Path) -> bool {
426 std::process::Command::new("git")
427 .args(["config", "--get", "core.hooksPath"])
428 .current_dir(root)
429 .output()
430 .map(|o| {
431 let val = String::from_utf8_lossy(&o.stdout);
432 val.trim() == ".asd/hooks"
433 })
434 .unwrap_or(false)
435}
436
437pub fn find_project_root(db_path: &PathBuf) -> PathBuf {
438 db_path
439 .parent()
440 .filter(|p| !p.as_os_str().is_empty())
441 .map(|p| p.to_path_buf())
442 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
443}
444
445#[cfg(test)]
446mod gitignore_tests {
447 use super::update_gitignore;
452 use tempfile::tempdir;
453
454 #[test]
455 fn fresh_repo_gets_sidecar_and_db_ignored() {
456 let tmp = tempdir().unwrap();
457 update_gitignore(tmp.path()).unwrap();
458 let gi = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
459 assert!(gi.lines().any(|l| l.trim() == ".asd-state.db"));
460 assert!(gi.lines().any(|l| l.trim() == ".asd/v1/"));
461 assert!(gi.lines().any(|l| l.trim() == ".asd/cache/"));
463 assert!(!gi.lines().any(|l| l.trim() == ".asd/conclusions/"));
464 }
465
466 #[test]
467 fn pre_existing_gitignore_is_preserved() {
468 let tmp = tempdir().unwrap();
469 std::fs::write(tmp.path().join(".gitignore"), "node_modules/\n*.log\n").unwrap();
470 update_gitignore(tmp.path()).unwrap();
471 let gi = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
472 assert!(gi.contains("node_modules/"));
473 assert!(gi.contains("*.log"));
474 assert!(gi.lines().any(|l| l.trim() == ".asd/v1/"));
475 }
476
477 #[test]
478 fn idempotent_on_already_correct_gitignore() {
479 let tmp = tempdir().unwrap();
480 let initial = ".asd-state.db\n.asd/v1/\n.asd/cache/\n";
481 std::fs::write(tmp.path().join(".gitignore"), initial).unwrap();
482 update_gitignore(tmp.path()).unwrap();
483 let gi = std::fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
484 assert_eq!(gi, initial);
485 }
486
487 #[test]
488 fn scaffold_creates_conclusions_and_cache_dirs() {
489 let tmp = tempdir().unwrap();
492 super::scaffold_sidecar_dirs(tmp.path()).unwrap();
493 assert!(tmp.path().join(".asd/conclusions").is_dir());
494 assert!(tmp.path().join(".asd/cache").is_dir());
495 assert!(tmp.path().join(".asd/conclusions/README.md").exists());
496 }
497}