From 097af1a455c936cfd8a7018081987cf22e354c0d Mon Sep 17 00:00:00 2001 From: Zuch Huang Date: Sun, 15 Mar 2026 13:42:25 +0800 Subject: [PATCH] Persist sandnote root path --- README.md | 3 + internal/cli/artifact.go | 18 ++++- internal/cli/artifact_reference.go | 23 +++---- internal/cli/root.go | 92 ++++++++++++++++++++++--- internal/cli/root_path_test.go | 107 +++++++++++++++++++++++++++++ internal/cli/thread.go | 8 ++- internal/store/fsstore/store.go | 93 +++++++++++++++++++++++-- 7 files changed, 314 insertions(+), 30 deletions(-) create mode 100644 internal/cli/root_path_test.go diff --git a/README.md b/README.md index ebcefaf..9b6ca71 100644 --- a/README.md +++ b/README.md @@ -173,8 +173,11 @@ Initialize a local store: ```bash sandnote init +sandnote init --root-path /path/to/repo ``` +`init` persists the notebook root path. Later commands automatically discover the nearest initialized `.sandnote` from the current directory upward, so running from subdirectories keeps using the same notebook. + Create a workspace, an entry, and a thread: ```bash diff --git a/internal/cli/artifact.go b/internal/cli/artifact.go index 668b6d0..5deef66 100644 --- a/internal/cli/artifact.go +++ b/internal/cli/artifact.go @@ -53,6 +53,10 @@ func newArtifactImportCommand(opts *rootOptions) *cobra.Command { if err != nil { return err } + rootPath, err := store.RootPath() + if err != nil { + return err + } entries := make([]model.Entry, 0, len(entryIDs)) for _, entryID := range entryIDs { @@ -97,7 +101,7 @@ func newArtifactImportCommand(opts *rootOptions) *cobra.Command { CreatedAt: nowUTC(), UpdatedAt: nowUTC(), } - prepareArtifactReference(opts.storeRoot, &artifact, data, info) + prepareArtifactReference(rootPath, &artifact, data, info) if artifact.IngestMode == model.ArtifactSnapshot { artifact.Body = string(data) } @@ -140,11 +144,15 @@ func newArtifactShowCommand(opts *rootOptions) *cobra.Command { if err != nil { return err } + rootPath, err := store.RootPath() + if err != nil { + return err + } artifact, err := store.LoadArtifact(args[0]) if err != nil { return err } - artifact, changed, err := resolveArtifactReference(opts.storeRoot, artifact) + artifact, changed, err := resolveArtifactReference(rootPath, artifact) if err != nil { return err } @@ -170,6 +178,10 @@ func newArtifactListCommand(opts *rootOptions) *cobra.Command { if err != nil { return err } + rootPath, err := store.RootPath() + if err != nil { + return err + } artifacts, err := store.ListArtifacts() if err != nil { return err @@ -177,7 +189,7 @@ func newArtifactListCommand(opts *rootOptions) *cobra.Command { items := make([]artifactListItem, 0, len(artifacts)) for _, artifact := range artifacts { - resolved, changed, err := resolveArtifactReference(opts.storeRoot, artifact) + resolved, changed, err := resolveArtifactReference(rootPath, artifact) if err != nil { return err } diff --git a/internal/cli/artifact_reference.go b/internal/cli/artifact_reference.go index 1ecb3fa..d79ff97 100644 --- a/internal/cli/artifact_reference.go +++ b/internal/cli/artifact_reference.go @@ -13,11 +13,10 @@ import ( var errArtifactFound = errors.New("artifact candidate found") -func prepareArtifactReference(storeRoot string, artifact *model.Artifact, sourceData []byte, info os.FileInfo) { - workspaceRoot := filepath.Clean(filepath.Dir(storeRoot)) +func prepareArtifactReference(rootPath string, artifact *model.Artifact, sourceData []byte, info os.FileInfo) { searchRoots := uniquePaths( filepath.Dir(artifact.SourceRef), - workspaceRoot, + rootPath, ) artifact.ContentDigest = digestBytes(sourceData) @@ -29,12 +28,12 @@ func prepareArtifactReference(storeRoot string, artifact *model.Artifact, source } } -func resolveArtifactReference(storeRoot string, artifact model.Artifact) (model.Artifact, bool, error) { +func resolveArtifactReference(rootPath string, artifact model.Artifact) (model.Artifact, bool, error) { if artifact.IngestMode != model.ArtifactReference { return artifact, false, nil } - currentPath, err := findArtifactPath(storeRoot, artifact) + currentPath, err := findArtifactPath(rootPath, artifact) if err != nil { return artifact, false, err } @@ -42,7 +41,7 @@ func resolveArtifactReference(storeRoot string, artifact model.Artifact) (model. return artifact, false, nil } - updated, err := refreshReferenceArtifact(storeRoot, artifact, currentPath) + updated, err := refreshReferenceArtifact(rootPath, artifact, currentPath) if err != nil { return artifact, false, err } @@ -50,7 +49,7 @@ func resolveArtifactReference(storeRoot string, artifact model.Artifact) (model. return updated, artifact.SourceRef != updated.SourceRef || artifact.ContentDigest != updated.ContentDigest, nil } -func refreshReferenceArtifact(storeRoot string, artifact model.Artifact, path string) (model.Artifact, error) { +func refreshReferenceArtifact(rootPath string, artifact model.Artifact, path string) (model.Artifact, error) { data, err := os.ReadFile(path) if err != nil { return artifact, err @@ -62,16 +61,16 @@ func refreshReferenceArtifact(storeRoot string, artifact model.Artifact, path st artifact.SourceRef = path artifact.UpdatedAt = nowUTC() - prepareArtifactReference(storeRoot, &artifact, data, info) + prepareArtifactReference(rootPath, &artifact, data, info) return artifact, nil } -func findArtifactPath(storeRoot string, artifact model.Artifact) (string, error) { +func findArtifactPath(rootPath string, artifact model.Artifact) (string, error) { if pathMatchesArtifactReference(artifact, artifact.SourceRef) { return artifact.SourceRef, nil } - for _, root := range searchRootsForArtifact(storeRoot, artifact) { + for _, root := range searchRootsForArtifact(rootPath, artifact) { found, err := scanArtifactRoot(root, artifact) if err != nil { return "", err @@ -84,12 +83,12 @@ func findArtifactPath(storeRoot string, artifact model.Artifact) (string, error) return "", nil } -func searchRootsForArtifact(storeRoot string, artifact model.Artifact) []string { +func searchRootsForArtifact(rootPath string, artifact model.Artifact) []string { roots := make([]string, 0, 4) if artifact.Locator != nil { roots = append(roots, artifact.Locator.SearchRoots...) } - roots = append(roots, filepath.Dir(artifact.SourceRef), filepath.Clean(filepath.Dir(storeRoot))) + roots = append(roots, filepath.Dir(artifact.SourceRef), filepath.Clean(rootPath)) return uniquePaths(roots...) } diff --git a/internal/cli/root.go b/internal/cli/root.go index cf59d26..000437a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -39,8 +39,8 @@ func NewRootCommand() *cobra.Command { cmd.PersistentFlags().StringVar( &opts.storeRoot, "root", - defaultStoreRoot(), - "filesystem root for sandnote state", + "", + "filesystem root for sandnote state; defaults to the nearest initialized .sandnote", ) cmd.AddCommand( @@ -59,31 +59,105 @@ func NewRootCommand() *cobra.Command { return cmd } -func defaultStoreRoot() string { +func resolvePath(path string) (string, error) { + if path == "" { + return "", nil + } + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + return filepath.Clean(abs), nil +} + +func currentWorkingDirectory() (string, error) { wd, err := os.Getwd() if err != nil { - return ".sandnote" + return "", err + } + return filepath.Clean(wd), nil +} + +func resolveCommandStoreRoot(root string) (string, error) { + if root != "" { + return resolvePath(root) + } + + wd, err := currentWorkingDirectory() + if err != nil { + return "", err + } + discovered, err := fsstore.DiscoverRoot(wd) + if err != nil { + return "", err + } + if discovered != "" { + return discovered, nil + } + return filepath.Join(wd, ".sandnote"), nil +} + +func resolveInitRootPath(rootPath, storeRoot string) (string, error) { + if rootPath != "" { + return resolvePath(rootPath) } - return filepath.Join(wd, ".sandnote") + if storeRoot != "" { + resolvedStoreRoot, err := resolvePath(storeRoot) + if err != nil { + return "", err + } + return filepath.Dir(resolvedStoreRoot), nil + } + return currentWorkingDirectory() +} + +func resolveInitStoreRoot(storeRoot, rootPath string) (string, error) { + if storeRoot != "" { + return resolvePath(storeRoot) + } + return filepath.Join(rootPath, ".sandnote"), nil } func newInitCommand(opts *rootOptions) *cobra.Command { - return &cobra.Command{ + var rootPath string + + cmd := &cobra.Command{ Use: "init", Short: "Initialize a filesystem-backed sandnote store", Example: joinLines( " sandnote init", + " sandnote init --root-path /path/to/repo", " sandnote --root /tmp/demo/.sandnote init", ), RunE: func(cmd *cobra.Command, args []string) error { - store := fsstore.New(opts.storeRoot) - if err := store.Init(); err != nil { + resolvedRootPath, err := resolveInitRootPath(rootPath, opts.storeRoot) + if err != nil { + return err + } + resolvedStoreRoot, err := resolveInitStoreRoot(opts.storeRoot, resolvedRootPath) + if err != nil { return err } - fmt.Fprintf(cmd.OutOrStdout(), "initialized sandnote store at %s\n", store.Root()) + + existingRoot, err := fsstore.DiscoverRoot(resolvedRootPath) + if err != nil { + return err + } + if existingRoot != "" && existingRoot != resolvedStoreRoot { + return fmt.Errorf("sandnote store is already initialized at %s", existingRoot) + } + + store := fsstore.New(resolvedStoreRoot) + if err := store.Init(resolvedRootPath); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "initialized sandnote store at %s for root path %s\n", store.Root(), resolvedRootPath) return nil }, } + + cmd.Flags().StringVar(&rootPath, "root-path", "", "root path for notebook-relative discovery and artifact resolution") + return cmd } func addNotImplementedSubcommands(parent *cobra.Command, names ...string) { diff --git a/internal/cli/root_path_test.go b/internal/cli/root_path_test.go new file mode 100644 index 0000000..911090b --- /dev/null +++ b/internal/cli/root_path_test.go @@ -0,0 +1,107 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sandbox0-ai/sandnote/internal/model" + "github.com/sandbox0-ai/sandnote/internal/store/fsstore" +) + +func executeCLIInDir(t *testing.T, dir string, args ...string) (*bytes.Buffer, error) { + t.Helper() + + t.Chdir(dir) + + cmd := NewRootCommand() + output := &bytes.Buffer{} + cmd.SetOut(output) + cmd.SetErr(output) + cmd.SetArgs(args) + err := cmd.Execute() + return output, err +} + +func TestInitPersistsRootPathAndRejectsNestedRepeat(t *testing.T) { + workspace := t.TempDir() + nested := filepath.Join(workspace, "services", "manager") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + output, err := executeCLIInDir(t, workspace, "init") + if err != nil { + t.Fatalf("init error = %v\noutput=%s", err, output.String()) + } + + storeRoot := filepath.Join(workspace, ".sandnote") + if !strings.Contains(output.String(), "initialized sandnote store at "+storeRoot+" for root path "+workspace) { + t.Fatalf("unexpected init output:\n%s", output.String()) + } + + store := fsstore.New(storeRoot) + marker, err := store.LoadMarker() + if err != nil { + t.Fatalf("LoadMarker() error = %v", err) + } + if marker.RootPath != workspace { + t.Fatalf("expected root path %q, got %+v", workspace, marker) + } + + _, err = executeCLIInDir(t, nested, "init") + if err == nil || !strings.Contains(err.Error(), "sandnote store is already initialized at "+storeRoot) { + t.Fatalf("expected nested repeat init to fail with existing store, got %v", err) + } +} + +func TestCommandsDiscoverStoreAndUsePersistedRootPath(t *testing.T) { + workspace := t.TempDir() + nested := filepath.Join(workspace, "apps", "dashboard") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("MkdirAll(nested) error = %v", err) + } + + if _, err := executeCLIInDir(t, workspace, "init"); err != nil { + t.Fatalf("init error = %v", err) + } + + sourceDir := filepath.Join(workspace, "docs") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("MkdirAll(sourceDir) error = %v", err) + } + oldPath := filepath.Join(sourceDir, "diagd-spec.md") + if err := os.WriteFile(oldPath, []byte("# diagd\ntrusted capability broker\n"), 0o644); err != nil { + t.Fatalf("WriteFile(oldPath) error = %v", err) + } + + importPath := filepath.Join("..", "..", "docs", "diagd-spec.md") + if _, err := executeCLIInDir(t, nested, "artifact", "import", importPath, "--id", "art_diagd", "--mode", "reference"); err != nil { + t.Fatalf("artifact import error = %v", err) + } + + archiveDir := filepath.Join(workspace, "archive") + if err := os.MkdirAll(archiveDir, 0o755); err != nil { + t.Fatalf("MkdirAll(archiveDir) error = %v", err) + } + newPath := filepath.Join(archiveDir, "diagd-spec-renamed.md") + if err := os.Rename(oldPath, newPath); err != nil { + t.Fatalf("Rename() error = %v", err) + } + + output, err := executeCLIInDir(t, nested, "artifact", "show", "art_diagd", "--json") + if err != nil { + t.Fatalf("artifact show error = %v\noutput=%s", err, output.String()) + } + + var got model.Artifact + if err := json.Unmarshal(output.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if got.SourceRef != newPath { + t.Fatalf("expected relocated artifact source_ref %q, got %+v", newPath, got) + } +} diff --git a/internal/cli/thread.go b/internal/cli/thread.go index a0b9752..b54dc63 100644 --- a/internal/cli/thread.go +++ b/internal/cli/thread.go @@ -615,9 +615,13 @@ func newThreadTransitionCommand(opts *rootOptions) *cobra.Command { } func requireStore(root string) (*fsstore.Store, error) { - store := fsstore.New(root) + resolvedRoot, err := resolveCommandStoreRoot(root) + if err != nil { + return nil, err + } + store := fsstore.New(resolvedRoot) if !store.Initialized() { - return nil, fmt.Errorf("sandnote store is not initialized at %s", root) + return nil, fmt.Errorf("sandnote store is not initialized at %s", resolvedRoot) } return store, nil } diff --git a/internal/store/fsstore/store.go b/internal/store/fsstore/store.go index 5e5b01e..904c0f1 100644 --- a/internal/store/fsstore/store.go +++ b/internal/store/fsstore/store.go @@ -21,7 +21,8 @@ type Store struct { } type Marker struct { - Version int `json:"version"` + Version int `json:"version"` + RootPath string `json:"root_path,omitempty"` } type REPLSession struct { @@ -86,10 +87,29 @@ func (s *Store) Root() string { return s.root } -func (s *Store) Init() error { +func (s *Store) Init(rootPath ...string) error { if s.root == "" { return errors.New("store root is required") } + resolvedRootPath := filepath.Dir(s.root) + if len(rootPath) > 0 && rootPath[0] != "" { + resolvedRootPath = rootPath[0] + } + resolvedRootPath, err := filepath.Abs(resolvedRootPath) + if err != nil { + return fmt.Errorf("resolve root path: %w", err) + } + resolvedRootPath = filepath.Clean(resolvedRootPath) + info, err := os.Stat(resolvedRootPath) + if err != nil { + return fmt.Errorf("stat root path: %w", err) + } + if !info.IsDir() { + return errors.New("root path must be a directory") + } + if s.Initialized() { + return fmt.Errorf("sandnote store is already initialized at %s", s.root) + } if err := os.MkdirAll(s.root, 0o755); err != nil { return fmt.Errorf("create store root: %w", err) } @@ -98,14 +118,79 @@ func (s *Store) Init() error { return fmt.Errorf("create %s directory: %w", dir, err) } } - return writeJSON(filepath.Join(s.root, markerFile), Marker{Version: 1}) + return writeJSON(filepath.Join(s.root, markerFile), Marker{ + Version: 1, + RootPath: resolvedRootPath, + }) } func (s *Store) Initialized() bool { - info, err := os.Stat(filepath.Join(s.root, markerFile)) + return IsInitializedRoot(s.root) +} + +func IsInitializedRoot(root string) bool { + info, err := os.Stat(filepath.Join(root, markerFile)) return err == nil && !info.IsDir() } +func DiscoverRoot(start string) (string, error) { + if start == "" { + return "", errors.New("start path is required") + } + current, err := filepath.Abs(start) + if err != nil { + return "", fmt.Errorf("resolve start path: %w", err) + } + current = filepath.Clean(current) + + info, err := os.Stat(current) + if err == nil && !info.IsDir() { + current = filepath.Dir(current) + } else if err != nil && !os.IsNotExist(err) { + return "", err + } + + for { + if IsInitializedRoot(current) { + return current, nil + } + + candidate := filepath.Join(current, ".sandnote") + if IsInitializedRoot(candidate) { + return candidate, nil + } + + parent := filepath.Dir(current) + if parent == current { + return "", nil + } + current = parent + } +} + +func (s *Store) LoadMarker() (Marker, error) { + if !s.Initialized() { + return Marker{}, errors.New("store is not initialized") + } + + var marker Marker + if err := s.loadFile(filepath.Join(s.root, markerFile), &marker); err != nil { + return Marker{}, err + } + if marker.RootPath == "" { + marker.RootPath = filepath.Clean(filepath.Dir(s.root)) + } + return marker, nil +} + +func (s *Store) RootPath() (string, error) { + marker, err := s.LoadMarker() + if err != nil { + return "", err + } + return marker.RootPath, nil +} + func (s *Store) SaveREPLSession(session REPLSession) error { if !s.Initialized() { return errors.New("store is not initialized")