Skip to content

Commit 8fe3f03

Browse files
authored
refactor: store embd config per-submodule instead of one root file (#59)
Each embedded folder now gets its own `.embd` file holding exactly one entry, mirroring how beman-submodule stores a .beman_submodule file inside each submodule. Drops the name-keyed shared Config in favor of EmbdEntry::load/save, and drops the now-redundant metadata.folder field since the file's location is the folder. status/update now take folder paths instead of names; add checks for an existing folder/.embd instead of a name collision. Embeds are discovered by walking the tree for .embd files when no folder args are given.
1 parent feff017 commit 8fe3f03

16 files changed

Lines changed: 501 additions & 537 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ cargo install --path .
2929
embd add -l <repo link>.git -f <local folder>
3030
```
3131

32-
This will clone the repository to the given folder and create an `embd.toml` file in the `.embd` folder. This file tracks both the pinned remote/commit for each embed and the hashes of the files it manages, and should be commited to VCS. You can also filter the contents of the repository using `-i` and `-e` flags. These use glob filters to include or exclude certain folders or files. For example, to exclude all Markdown and include all text files, you could the following:
32+
This will clone the repository to the given folder and create a `.embd` file inside it. This file tracks both the pinned remote/commit for the embed and the hashes of the files it manages, and should be commited to VCS. You can also filter the contents of the repository using `-i` and `-e` flags. These use glob filters to include or exclude certain folders or files. For example, to exclude all Markdown and include all text files, you could the following:
3333

3434
```bash
3535
embd add -l <repo> -f <folder> -i "**.txt" -e "**.md"
@@ -43,7 +43,7 @@ To update all embedded projects, run:
4343
embd update
4444
```
4545

46-
To update a specific project, use the name of the repo. This corresponds to the key of the projects entry in the `.embd/embd.toml` file:
46+
To update a specific project, use its folder path:
4747

4848
```bash
4949
embd update infra
@@ -55,7 +55,7 @@ To update a project to a new commit, tag or branch, use the `-r` or `--rev` flag
5555
embd update infra --rev abcd1234
5656
```
5757

58-
This will update the files on disk and update the commit hash and file manifest tracked for that project in `embd.toml`. Updates can also be forced using `--force` and untracked files can be removed using `--overwrite`. See `embd update -h` for more details.
58+
This will update the files on disk and update the commit hash and file manifest tracked for that project in its `.embd` file. Updates can also be forced using `--force` and untracked files can be removed using `--overwrite`. See `embd update -h` for more details.
5959

6060
### Check Status of Embeds
6161

‎docs/design.md‎

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,25 @@ The general idea is to pull the dependent repository as source into the parent r
1616

1717
### Commands
1818

19-
| Command | Description | Options |
20-
| :------: | :-----------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
21-
| `add` | Add a new embedded repo to the project. | `folder`: Specify what subdirectory to put embed in. \n `allow-untracked`: Allow untracked files in the embed directory. \n `link`: The link to the project to include \n `rev`: Specify the commit,tag or branch to pull \n `include`: Glob patterns to include \n `exclude`: Glob patterns to exclude |
22-
| `update` | Update all embeds to match the config file. | `rev`: Specify the commit, tag or branch to advance to \n `force`: Overwrite any local modification \n `overwrite`: Delete all untracked files (requries `force`) \n `quiet`: Print summaries, not each file updates. |
23-
| `status` | Show the status of all embeds. | `quiet`: Should summaries per embed, not per file. |
19+
| Command | Description | Options |
20+
| :------: | :---------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
21+
| `add` | Add a new embedded repo to the project. | `folder`: Specify what subdirectory to put embed in. \n `allow-untracked`: Allow untracked files in the embed directory. \n `link`: The link to the project to include \n `rev`: Specify the commit,tag or branch to pull \n `include`: Glob patterns to include \n `exclude`: Glob patterns to exclude |
22+
| `update` | Update all embeds to match their pinned config. | `rev`: Specify the commit, tag or branch to advance to \n `force`: Overwrite any local modification \n `overwrite`: Delete all untracked files (requries `force`) \n `quiet`: Print summaries, not each file updates. |
23+
| `status` | Show the status of all embeds. | `quiet`: Should summaries per embed, not per file. |
2424

2525
### Configuration File
2626

27-
`embd` keeps a single consolidated file, `.embd/embd.toml`, describing all the embedded sources. Each entry has a `metadata` table which includes data like the remote repository URL, commit, folder, and include/exclude filters. The config entry also includes a `files` table of sha256 hashes per tracked file. This is used to detect local drift without having to re-pull or clone files from the remote.
27+
`embd` stores one configuration file per embed, named `.embd`, inside the embedded folder itself. This mirrors how [beman-submodule](https://github.com/bemanproject/beman-submodule) stores a `.beman_submodule` file inside each submodule it manages. There's no project-wide config file; `embd` discovers embeds by walking the project tree for `.embd` files.
28+
29+
Each file has a `metadata` table with the remote repository URL, commit, and include/exclude filters, plus a `files` table of sha256 hashes per tracked file, used to detect local drift without re-pulling or cloning files from the remote. The embed's folder isn't stored in the file — it's implied by the file's own location.
2830

2931
```toml
30-
[repo1.metadata]
32+
# example/.embd
33+
[metadata]
3134
remote = "https://example.git"
3235
commit_hash = "123abcd1234"
33-
folder = "example"
3436
allow_untracked = false
3537

36-
[repo1.files]
38+
[files]
3739
"a.txt" = "sha256:..."
38-
39-
[repo2.metadata]
40-
remote = "https://example2.git"
41-
commit_hash = "123abcd1234"
42-
folder = "example2"
43-
allow_untracked = false
44-
45-
[repo2.files]
46-
"b.txt" = "sha256:..."
4740
```
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
[infra.metadata]
1+
[metadata]
22
remote = "git@github.com:ptsouchlos/infra.git"
33
commit_hash = "79932afc417d21900038dcd8f8154c3661c15f76"
4-
folder = "infra"
54
allow_untracked = false
65
include = [
76
"just/**",
87
"rust/**",
98
]
109

11-
[infra.files]
10+
[files]
1211
"just/shells.just" = "sha256:ca031066da941b192b18859a4c8ebda1ecfe91ff14f57fe31489c6e83cb3bfa7"
1312
"rust/just/clippy.just" = "sha256:5ce758826bbd8f4f3ffd626792edb06ab1fe41c591f7cd099c59336c46a1081f"
1413
"rust/just/format.just" = "sha256:cb2e821139f1e314f5b87e4282bb75524d9d90cdd0dee1eaa524b8fa4b84f762"

‎src/color.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ pub(crate) fn bad(s: &str) -> String {
5050
s.red().to_string()
5151
}
5252

53-
/// Bold: entry headers (`name (folder)`).
53+
/// Bold: entry headers (the embed's folder path).
5454
pub(crate) fn header(s: &str) -> String {
5555
s.bold().to_string()
5656
}

‎src/commands/add.rs‎

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use anyhow::{Context, Result, bail};
44
use tempfile::tempdir;
55

66
use crate::color;
7-
use crate::config::{self, EmbdEntry, FileLocks, Metadata};
7+
use crate::config::{EmbdEntry, FileLocks, Metadata};
88
use crate::filter::Filter;
99
use crate::{filesystem, git, paths};
1010

@@ -52,24 +52,22 @@ fn parse_patterns(arg: &Option<String>) -> Vec<String> {
5252

5353
pub(crate) fn execute(args: AddArgs) -> Result<()> {
5454
let root = paths::find_git_root()?;
55-
let config_path = paths::config_path(&root);
5655
let cwd = std::env::current_dir().context("failed to read current directory")?;
5756

5857
// Validate the link and filter patterns before doing any I/O.
59-
let (link, repo_name) = git::parse_repo_link(&args.link)?;
60-
let (folder_abs, folder_rel) = paths::resolve_inside_root(&args.folder, &root, &cwd)?;
58+
// `_repo_name` is unused: embeds are identified by folder path, not by a
59+
// derived repo name, but `parse_repo_link` still validates it.
60+
let (link, _repo_name) = git::parse_repo_link(&args.link)?;
61+
let (folder_abs, _folder_rel) = paths::resolve_inside_root(&args.folder, &root, &cwd)?;
6162
let include = parse_patterns(&args.include);
6263
let exclude = parse_patterns(&args.exclude);
6364
let filter = Filter::from_patterns(&include, &exclude)?;
6465

65-
// Load the config, or use a default one
66-
let mut config = config::load_or_default(&config_path)?;
67-
68-
// Check if the config already contains an entry for the given repo.
69-
if config.contains(&repo_name) {
66+
let config_path = paths::embed_file_path(&folder_abs);
67+
if config_path.exists() {
7068
bail!(
71-
"an embed named '{}' already exists in {}",
72-
repo_name,
69+
"'{}' is already an embed ({} exists)",
70+
args.folder.display(),
7371
config_path.display()
7472
);
7573
}
@@ -104,28 +102,26 @@ pub(crate) fn execute(args: AddArgs) -> Result<()> {
104102
// Hash the destination folder as-copied (not just what the filter
105103
// allowed) so a pre-existing --allow-untracked folder's contents are
106104
// reflected too, matching what `copy_dir` actually left on disk.
105+
// `.embd` itself is excluded by `filesystem::is_skipped_entry`, so
106+
// it never ends up hashing itself.
107107
let files = FileLocks::build_from_path(&folder_abs)?;
108-
config.insert(
109-
repo_name.clone(),
110-
EmbdEntry {
111-
metadata: Metadata {
112-
remote: link,
113-
commit_hash,
114-
folder: folder_rel,
115-
allow_untracked: args.allow_untracked,
116-
include,
117-
exclude,
118-
},
119-
files,
108+
let entry = EmbdEntry {
109+
metadata: Metadata {
110+
remote: link,
111+
commit_hash,
112+
allow_untracked: args.allow_untracked,
113+
include,
114+
exclude,
120115
},
121-
)?;
122-
config.save(&config_path)?;
116+
files,
117+
};
118+
entry.save(&config_path)?;
123119
Ok(())
124120
})();
125121

126122
if let Err(e) = result {
127123
// If there was an error, try to rollback to the previous state. Nothing
128-
// is written to config.toml unless the whole closure above succeeds, so
124+
// is written to disk unless the whole closure above succeeds, so
129125
// there's no separate on-disk manifest entry to clean up here.
130126
rollback(&folder_abs, folder_existed, args.allow_untracked);
131127
// Propagate the error up to the caller

‎src/commands/common.rs‎

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,56 @@
1-
use anyhow::{Context, Result};
1+
use std::path::{Path, PathBuf};
22

3-
use crate::config::{Config, EmbdEntry};
3+
use anyhow::{Context, Result, bail};
44

5-
/// Resolve a list of user-supplied names to `(name, entry)` pairs. An empty
6-
/// `names` slice means "all entries, in deterministic order."
7-
pub(crate) fn select_entries<'a>(
8-
config: &'a Config,
9-
names: &'a [String],
10-
) -> Result<Vec<(&'a str, &'a EmbdEntry)>> {
11-
if names.is_empty() {
12-
return Ok(config.iter().collect());
13-
}
14-
let mut out = Vec::with_capacity(names.len());
15-
for name in names {
16-
let entry = config
17-
.get(name)
18-
.with_context(|| format!("no embed named '{name}' in config"))?;
19-
out.push((name.as_str(), entry));
5+
use crate::color;
6+
use crate::config::EmbdEntry;
7+
use crate::paths;
8+
9+
/// Resolve a list of user-supplied folder paths to `(folder, entry)` pairs,
10+
/// each folder relative to `root`. An empty `folders` slice means "every
11+
/// embed discovered under `root`, in deterministic (sorted) order."
12+
pub(crate) fn select_entries(
13+
root: &Path,
14+
folders: &[String],
15+
cwd: &Path,
16+
) -> Result<Vec<(PathBuf, EmbdEntry)>> {
17+
if folders.is_empty() {
18+
let discovered = paths::discover_embed_folders(root)?;
19+
if discovered.is_empty() && root.join(".embd").is_dir() {
20+
bail!(
21+
"found a legacy '.embd/' project-level config directory; embd now stores one \
22+
.embd file per embedded folder instead of one file per project. See \
23+
docs/design.md for the new layout, and convert existing entries by hand \
24+
(there is no automated migration)."
25+
);
26+
}
27+
28+
let mut out = Vec::new();
29+
for folder in discovered {
30+
let config_path = paths::embed_file_path(&root.join(&folder));
31+
match EmbdEntry::load(&config_path)
32+
.context("failed to load embed config found during discovery")
33+
{
34+
Ok(entry) => out.push((folder, entry)),
35+
Err(e) => {
36+
anstream::eprintln!(
37+
"{} skipping '{}': {e:#}",
38+
color::warning_label(),
39+
folder.display()
40+
);
41+
}
42+
}
43+
}
44+
Ok(out)
45+
} else {
46+
let mut out = Vec::with_capacity(folders.len());
47+
for folder_arg in folders {
48+
let (_, folder_rel) = paths::resolve_inside_root(Path::new(folder_arg), root, cwd)?;
49+
let config_path = paths::embed_file_path(&root.join(&folder_rel));
50+
let entry = EmbdEntry::load(&config_path)
51+
.with_context(|| format!("no embed found at '{folder_arg}'"))?;
52+
out.push((folder_rel, entry));
53+
}
54+
Ok(out)
2055
}
21-
Ok(out)
2256
}

‎src/commands/status.rs‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use anyhow::Result;
1+
use anyhow::{Context, Result};
22

33
use crate::color;
44
use crate::commands::common::select_entries;
@@ -7,22 +7,21 @@ use crate::paths;
77

88
#[derive(clap::Args, Debug)]
99
pub(crate) struct StatusArgs {
10-
/// Optional names to check. When empty, all embeds in the config are checked.
11-
names: Vec<String>,
10+
/// Optional folder paths to check. When empty, every embed in the project is checked.
11+
folders: Vec<String>,
1212
/// Suppress per-file rows; print only one-line summaries per entry.
1313
#[clap(short, long)]
1414
quiet: bool,
1515
}
1616

1717
pub(crate) fn execute(args: StatusArgs) -> Result<()> {
1818
let root = paths::find_git_root()?;
19-
let config_path = paths::config_path(&root);
20-
let config = config::load_or_default(&config_path)?;
21-
let selected = select_entries(&config, &args.names)?;
19+
let cwd = std::env::current_dir().context("failed to read current directory")?;
20+
let selected = select_entries(&root, &args.folders, &cwd)?;
2221

2322
let mut any_drift = false;
24-
for (name, entry) in selected {
25-
let report = config::inspect_entry(&root, name, entry);
23+
for (folder, entry) in &selected {
24+
let report = config::inspect_entry(&root, folder, entry);
2625
any_drift |= report.has_drift();
2726
print_report(&report, args.quiet);
2827
}
@@ -36,7 +35,7 @@ pub(crate) fn execute(args: StatusArgs) -> Result<()> {
3635
pub(crate) fn print_report(report: &EntryReport, quiet: bool) {
3736
use anstream::println;
3837

39-
let header = color::header(&format!("{} ({})", report.name, report.folder.display()));
38+
let header = color::header(&report.folder.display().to_string());
4039

4140
match report.state {
4241
EntryState::FolderMissing => {

0 commit comments

Comments
 (0)