Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions smallfactory/cli/sf_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
update_entity_fields as ent_update_entity_fields,
retire_entity as ent_retire_entity,
# Revisions APIs
cut_revision as ent_cut_revision,
bump_revision as ent_bump_revision,
release_revision as ent_release_revision,
# BOM APIs
Expand Down Expand Up @@ -170,6 +171,11 @@ def main():
ent_rev_bump.add_argument("--notes", default=None, help="Optional notes for revision metadata (applied to snapshot and release)")
ent_rev_bump.add_argument("--released-at", dest="released_at", default=None, help="ISO datetime for release (default now)")

ent_rev_new = rev_sub.add_parser("new", help="Create a new draft revision")
ent_rev_new.add_argument("sfid", help="Part SFID (e.g., p_widget)")
ent_rev_new.add_argument("rev", help="Revision label to create (e.g., A, B, ...)")
ent_rev_new.add_argument("--notes", default=None, help="Optional release notes")

ent_rev_release = rev_sub.add_parser("release", help="Mark a revision as released and update the 'released' pointer")
ent_rev_release.add_argument("sfid", help="Part SFID (e.g., p_widget)")
ent_rev_release.add_argument("rev", help="Revision label to release (e.g., A, B, ...)")
Expand Down Expand Up @@ -1001,6 +1007,26 @@ def cmd_entities_rev_bump(args):
else:
print(f"[smallFactory] Created and released revision '{res.get('rev')}' for '{args.sfid}'")

def cmd_entities_rev_new(args):
datarepo_path = _repo_path()
try:
res = ent_cut_revision(
datarepo_path,
args.sfid,
args.rev,
notes=getattr(args, "notes", None),
)
except Exception as e:
print(f"[smallFactory] Error: {e}")
sys.exit(1)
fmt = _fmt()
if fmt == "json":
print(json.dumps(res, indent=2))
elif fmt == "yaml":
print(yaml.safe_dump(res, sort_keys=False))
else:
print(f"[smallFactory] Created revision '{args.rev}' for '{args.sfid}'")

def cmd_entities_rev_release(args):
datarepo_path = _repo_path()
try:
Expand Down Expand Up @@ -1315,6 +1341,7 @@ def cmd_web(args):
("entities", "set"): cmd_entities_set,
("entities", "retire"): cmd_entities_retire,
("entities", "revision:bump"): cmd_entities_rev_bump,
("entities", "revision:new"): cmd_entities_rev_new,
("entities", "revision:release"): cmd_entities_rev_release,
("entities", "files:ls"): cmd_entities_files_ls,
("entities", "files:mkdir"): cmd_entities_files_mkdir,
Expand Down
5 changes: 3 additions & 2 deletions smallfactory/core/v1/SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Validation coverage:

• **`files/`** — Working area for in‑progress files (e.g., CAD and documentation). Included in snapshots when you cut a revision. There is no prescribed substructure under `files/`; organize as needed.

• **`revisions/<rev>/`** — Immutable snapshot for a specific revision label. Treat contents as readonly once created (and especially once released).
• **`revisions/<rev>/`** — Snapshot for a specific revision label. Once released, becomes immutable, and contents should be treated as read-only. (Conversely, a draft revision must not be incorporated into a released revision.)

• **`revisions/<rev>/meta.yml`** — Snapshot metadata (rev, status, source commit, notes, artifact list, hashes, etc.).

Expand Down Expand Up @@ -440,6 +440,7 @@ Algorithm (conceptual):
- Determine target revision:
- If `rev` is a **label** (e.g., "B"), use it.
- If `rev` is **`released`**, read `entities/<use>/refs/released`. If this file is missing and the part has `policy: buy` with no `revisions/`, treat it as an implicit released snapshot.
- If releasing, all child BOM lines must point to released versions.
- If the chosen rev does not exist or `status` ≠ `released`:
- Try `alternates` in order, then `alternates_group` (pick any **released** member).
- If none valid → error.
Expand Down Expand Up @@ -476,7 +477,7 @@ sf build update <b_sfid> [--status <open|in_progress|completed|canceled>] [--qty
- The `files/` workspace is free-form; no default subfolders are created. Users may create folders as needed via the Files APIs/CLI/UI.
- No legacy aliases: the `children` key MUST NOT appear.
- For `policy: buy` parts, `revisions/` and `refs/` may be omitted; such parts are treated as having an implicit released snapshot.
- Revision directories under `revisions/` are **immutable** once released.
- Revision directories under `revisions/` are **immutable** once released. Revisions can be replaced while still a draft.
- `refs/released` is the **only pointer** you flip to advance the world.
- Large binaries (`*.step`, `*.stl`, `*.pdf`) should be tracked with **Git LFS**.
- SFIDs MUST be globally unique and never reused; prefixes recommended (e.g., `p_`, `l_`, `b_`).
Expand Down
10 changes: 8 additions & 2 deletions smallfactory/core/v1/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,13 @@ def cut_revision(
label = rev or _compute_next_label_from_fs(datarepo_path, sfid)
snap_dir = _revisions_dir(datarepo_path, sfid) / label
if snap_dir.exists():
raise FileExistsError(f"Revision '{label}' already exists for {sfid}")
# Ok, it exists. But maybe it's a draft, and we can blow it away.
meta = _read_meta(snap_dir / "meta.yml")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

meta["status"] should likely be meta.get("status") to avoid KeyError on malformed/old meta.

if meta != {} and meta.get("status", "draft") != "draft":
raise FileExistsError(f"Revision '{label}' already exists for {sfid} and is not draft")
commit_paths = [snap_dir]
msg = f"[smallFactory] Delete stale draft revision {sfid} {label}\n::sfid::{sfid}\n::sf-rev::{label}\n::sf-op::rev-delete"
git_commit_paths(datarepo_path, commit_paths, msg, delete=True)
(snap_dir).mkdir(parents=True, exist_ok=True)

# Copy entire entity directory excluding the 'revisions' subtree
Expand All @@ -320,7 +326,7 @@ def cut_revision(
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(child, dest)
elif child.is_dir():
shutil.copytree(child, dest)
shutil.copytree(child, dest, dirs_exist_ok=True)

# Build and persist a resolved BOM tree for this snapshot before hashing artifacts
try:
Expand Down
2 changes: 1 addition & 1 deletion smallfactory/core/v1/gitutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def git_commit_paths(repo_path: Path, paths: list[Path], message: str, delete: b
if delete:
if p.exists():
# git rm will remove from working tree and stage deletion
subprocess.run(["git", "rm", "-f", str(p)], cwd=repo_path, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(["git", "rm", "-fr", str(p)], cwd=repo_path, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
# If it doesn't exist, nothing to stage; ignore
continue
Expand Down
9 changes: 5 additions & 4 deletions web/templates/entities/view.html
Original file line number Diff line number Diff line change
Expand Up @@ -1147,15 +1147,16 @@ <h2 class="text-lg font-semibold text-gray-900">Bill of Materials</h2>
const tbody = document.getElementById('revisions-tbody');
if(!tbody) return;
tbody.innerHTML = '';
const releasedOnly = Array.isArray(list) ? list.filter(r => r && r.released_at) : [];
if(releasedOnly.length===0){
const releases = Array.isArray(list) ? list : [];
if(releases.length===0){
tbody.innerHTML = '<tr><td colspan="6" class="px-3 py-2 text-sm text-gray-500">No releases yet.</td></tr>';
return;
}
releasedOnly.forEach(r => {
releases.forEach(r => {
const id = r.id||'—';
const ra = r.released_at||'—';
const st = (r && r.released_at) ? 'released' : (r && r.status ? r.status : '—');
const st_color = st == 'released' ? 'bg-green-100 text-green-800' : 'bg-amber-100 text-amber-800';
const ca = r.created_at||'—';
const notesRaw = r.notes ? String(r.notes) : '';
const notesHtml = notesRaw ? _nl2br(_escapeHtml(notesRaw)) : '';
Expand All @@ -1167,7 +1168,7 @@ <h2 class="text-lg font-semibold text-gray-900">Bill of Materials</h2>
tbody.insertAdjacentHTML('beforeend', `
<tr class="odd:bg-gray-50">
<td class="px-3 py-1.5 font-mono text-blue-700">${id}</td>
<td class="px-3 py-1.5 text-gray-800">${st}</td>
<td class="px-3 py-1.5"><span class="px-2 py-0.5 rounded-full text-xs ${st_color}">${st}</span></td>
<td class="px-3 py-1.5 text-gray-700">${ca}</td>
<td class="px-3 py-1.5 text-gray-700">${ra}</td>
<td class="px-3 py-1.5 text-gray-700 whitespace-pre-wrap break-words">${notesHtml || '<span class=\'text-gray-400\'>—</span>'}</td>
Expand Down