Skip to main content

agentstatedeveloper_cli/commands/
init.rs

1//! `asd init [--no-hooks]` — create (or reuse) an ASD repository, stamp
2//! the schema-version marker, install git hooks, and update .gitignore.
3//!
4//! Git hooks are written to `.asd/hooks/` and activated via
5//! `git config core.hooksPath .asd/hooks`. This means the hook scripts
6//! travel with the repo and any contributor who runs `asd init` gets
7//! them automatically. Pass `--no-hooks` to skip hook installation.
8
9use std::fs;
10// Plan N t-001 (1.1.23): Unix-only — `PermissionsExt::from_mode` is gated
11// to unix targets. On Windows the chmod is a no-op (git executes hooks
12// regardless of file mode), so we just skip the call rather than emulate.
13#[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
27// ---------------------------------------------------------------------------
28// Hook definitions — single source of truth for scripts + metadata
29// ---------------------------------------------------------------------------
30
31struct 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// ---------------------------------------------------------------------------
106// Args
107// ---------------------------------------------------------------------------
108
109#[derive(Debug, Args)]
110pub struct InitArgs {
111    /// Skip git hook installation. Hooks can be installed later by
112    /// re-running `asd init` without this flag.
113    #[arg(long, default_value_t = false)]
114    pub no_hooks: bool,
115}
116
117// ---------------------------------------------------------------------------
118// Run
119// ---------------------------------------------------------------------------
120
121pub fn run(cfg: &Config, args: InitArgs) -> Result<()> {
122    // Ensure parent directory exists for the sqlite file.
123    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    // Locate the project root (directory containing the db file, or cwd).
145    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.
153    update_gitignore(&project_root)?;
154
155    // Ensure `.asd/conclusions/*.jsonl` use the union merge driver so
156    // concurrent branches never raise a textual conflict on the sidecar.
157    update_gitattributes(&project_root)?;
158
159    // Plan B t-006: scaffold the two new sidecar subdirs.
160    // `.asd/conclusions/` is the committed compact-JSONL home.
161    // `.asd/cache/` is the gitignored derived-cache home (call graph, etc.).
162    scaffold_sidecar_dirs(&project_root)?;
163
164    // Install hooks unless --no-hooks.
165    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
174// ---------------------------------------------------------------------------
175// .gitignore management
176// ---------------------------------------------------------------------------
177
178fn 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    // A blanket `.asd/` (or `.asd`) ignore would also hide `.asd/conclusions/`,
190    // which is committed under Plan B. Strip it; the specific derived-state
191    // entries below keep only the regenerable caches ignored.
192    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    // Ensure the SQLite db is ignored.
202    if !lines.iter().any(|l| l.trim() == ".asd-state.db") {
203        lines.push(".asd-state.db".to_string());
204        changed = true;
205    }
206
207    // Plan A t-003: derived sidecar (tens of MB) — regenerable, stays local.
208    if !lines.iter().any(|l| l.trim() == ".asd/v1/") {
209        lines.push(".asd/v1/".to_string());
210        changed = true;
211    }
212
213    // Plan B t-006: derived cache (call graph, effects-rev, etc.). Same
214    // principle — regenerable from source, stays local. `.asd/conclusions/`
215    // (the committed compact JSONL) is intentionally NOT ignored.
216    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    // If the sidecar is already tracked from a prior install, tell the user
231    // how to untrack it. Don't run `git rm` ourselves — destructive ops belong
232    // to the user.
233    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
252// ---------------------------------------------------------------------------
253// .gitattributes management (conclusions merge driver)
254// ---------------------------------------------------------------------------
255
256/// Marker line binding the committed conclusions JSONL to the union merge
257/// driver registered by [`install_hooks`]. `.gitattributes` is committed so the
258/// binding travels to every clone; the driver itself is registered per-clone in
259/// git config (a fresh clone gets it on its own `asd init`).
260const 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
284// ---------------------------------------------------------------------------
285// Plan B sidecar scaffolding
286// ---------------------------------------------------------------------------
287
288/// Create `.asd/conclusions/` and `.asd/cache/`. Drop a small README in
289/// conclusions/ so git tracks the directory before any JSONL is exported.
290fn 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
312// ---------------------------------------------------------------------------
313// Hook installation
314// ---------------------------------------------------------------------------
315
316fn 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        // Make executable (owner + group + other execute bits).
328        // Windows: no-op — Git for Windows ignores file mode and runs
329        // hooks via the configured shell regardless.
330        #[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    // Point git at our hooks directory.
337    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    // Register the conclusions union merge driver referenced by
345    // `.gitattributes` (merge=asdconclusions). git config is per-clone, so this
346    // runs on every `asd init` — a fresh clone gets the driver here even though
347    // the `.gitattributes` binding arrived with the checkout.
348    let driver_set = register_merge_driver(root);
349
350    // Print the hook table.
351    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
381/// Register the `asdconclusions` git merge driver in the repo's local git
382/// config. Returns whether both config writes succeeded.
383fn 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
400// ---------------------------------------------------------------------------
401// Helper used by `asd hooks` to read installed hook status
402// ---------------------------------------------------------------------------
403
404pub 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    //! Regression probe for Plan A t-003: the `.asd/v1/` sidecar must be
448    //! gitignored, not tracked. If this test fails, init has reverted to the
449    //! pre-Plan-A "ride sidecar in git" model.
450
451    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        // Plan B t-006: cache also ignored; conclusions/ intentionally not.
462        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        // Plan B t-006: init creates both subdirs; conclusions/ gets a
490        // tracked README so git follows the directory.
491        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}