Skip to content
Draft
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ dependencies = [
"setuptools<72",
"nbformat",
"graphviz",
"dulwich"
]

[project.optional-dependencies]
Expand Down
4 changes: 4 additions & 0 deletions src/noworkflow/now/cmd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from .cmd_gc import GC
from .cmd_evaluation import Evaluation
from .cmd_clean import Clean
from .cmd_branch import Branch
from ..utils.io import print_msg


Expand Down Expand Up @@ -59,7 +60,9 @@ def main():
GC(),
Evaluation(),
Clean(),
Branch()
]

for cmd in commands:
cmd.create_parser(subparsers)

Expand Down Expand Up @@ -95,4 +98,5 @@ def main():
"Push",
"Pull",
"Import",
"Branch",
]
68 changes: 68 additions & 0 deletions src/noworkflow/now/cmd/cmd_branch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Copyright (c) 2026 Universidade Federal Fluminense (UFF)
# This file is part of noWorkflow.
# Please, consult the license terms in the LICENSE file.
"""'now branch' command"""
from __future__ import (absolute_import, print_function,
division, unicode_literals)

import os

from ..persistence import persistence_config, content
from ..persistence.models import Trial
from ..utils.io import print_msg

from .command import Command


class Branch(Command):
"""Manage noWorkflow Git branches"""

def add_arguments(self):
add_arg = self.add_argument
add_arg("--dir", type=str,
help="set project path where is the database. Default to "
"current directory")
add_arg("--content-engine", type=str,
help="set the content database engine")

subparsers = self.parser.add_subparsers(dest="branch_cmd")

subparsers.add_parser("list", help="list branches")
subparsers.add_parser("current", help="show current branch")

switch = subparsers.add_parser("switch", help="switch branch")
switch.add_argument("name", type=str)

rename = subparsers.add_parser("rename", help="rename branch")
rename.add_argument("old", type=str)
rename.add_argument("new", type=str)

def _connect(self, args):
persistence_config.content_engine = args.content_engine
persistence_config.connect_existing(args.dir or os.getcwd())
if not hasattr(content.content_database_engine, "branches"):
raise RuntimeError(
"Git branch prototype requires a Git content engine"
)

def execute(self, args):
self._connect(args)
command = args.branch_cmd or "list"
current, _ = content.current_branch()

if command == "list":
for name in content.branches():
marker = "*" if name == current else " "
print("{} {}".format(marker, name))
elif command == "current":
print("{}".format(current))
elif command == "switch":
# TODO: IMPLEMENT this
# content.checkout_branch(args.name)
# trial_id = content.get_branch_head_trial_id(args.name)
# if trial_id:
# Trial(trial_id).create_head()
print_msg("Switched to branch {}".format(args.name), True)
elif command == "rename":
content.rename_branch(args.old, args.new)
print_msg("Renamed branch {} to {}".format(args.old, args.new), True)
5 changes: 4 additions & 1 deletion src/noworkflow/now/cmd/cmd_restore.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,10 @@ def create_backup(self, metascript, files, args):

print_msg("Backup Trial {} created".format(metascript.trial_id),
self.print_msg)
content.commit_content(metascript.message or "Backup Trial {}".format(metascript.trial_id))
content.commit_content(
metascript.message or "Backup Trial {}".format(metascript.trial_id),
trial_id=metascript.trial_id
)

def restore(self, path, code_hash, trial_id, mode="normal"):
"""Restore file with <code_hash> from <trial_id>"""
Expand Down
9 changes: 7 additions & 2 deletions src/noworkflow/now/cmd/cmd_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,14 @@ def run(metascript, args=None):
Tag.create_automatic_tag(*metascript.create_automatic_tag_args())
Trial.set_user_based_on_env(metascript.trial_id)
metaprofiler.meta_profiler.save()
content.commit_content(metascript.message or "Trial {}".format(metascript.trial_id))

content.commit_content(
metascript.message or "Trial {}".format(metascript.trial_id),
trial_id=metascript.trial_id
)
finally:
metascript.create_last()
content.close()

class Run(Command):
"""Run a script collecting its provenance"""
Expand Down Expand Up @@ -155,7 +160,7 @@ def add_arguments(self):
help="add a message to the commit of the trial")
add_arg("--content-engine", type=str,
help="set the content database engine")


# Internal
add_cmd("--create_last", action="store_true", help=argparse.SUPPRESS)
Expand Down
2 changes: 1 addition & 1 deletion src/noworkflow/now/persistence/content/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def gc(self, content_hash):
"""Collect garbage from database"""
raise NotImplementedError("Implement in subclass")

def commit_content(self, message):
def commit_content(self, message, trial_id=None):
"""Commit content"""
raise NotImplementedError("Implement in subclass")

Expand Down
177 changes: 169 additions & 8 deletions src/noworkflow/now/persistence/content/dulwich_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .gitbase import GitContentDatabaseEngine
from .parallel import create_distributed, create_pool, create_threading, NullLock
from . import safeopen
from ..models import Trial

class DulwichEngine(GitContentDatabaseEngine):

Expand All @@ -29,9 +30,12 @@ def connect(self, config):
os.makedirs(self.content_path)
Repo.init_bare(self.content_path)
self.repo = Repo(self.content_path)
self._commit_ref = self.branch_ref(self._default_branch)
self._set_head(self._commit_ref)
self.create_initial_commit()
else:
self.repo = Repo(self.content_path)
_, self._commit_ref = self.current_branch()

@staticmethod
def do_put(content_path, object_hashes, lock, content, filename):
Expand Down Expand Up @@ -79,18 +83,30 @@ def create_initial_commit(self):
object_store = self.repo.object_store
empty_tree = Tree()
object_store.add_object(empty_tree)
self.create_commit_object(self._initial_message, empty_tree.id)
commit_id = self.create_commit_object(self._initial_message, empty_tree.id)
self.repo.refs[(self._commit_ref).encode("utf-8")] = commit_id
self.switch_branch(self._default_branch)

def create_commit_object(self, message, tree):
def create_commit_object(self, message, tree, trial_id=None):
"""Create a commit object"""
with self.use_safe_open():
master_ref = self.repo.get_refs().get(
self._commit_ref.encode("utf-8"), None
)

commit = Commit()
if master_ref is not None:
commit.parents = [master_ref]

if trial_id != None:
current_branch, current_branch_ref = self.current_branch()
branch_head_commit_id = self.get_branch_head_commit_id(current_branch)
branch_head_trial_id = self.get_branch_head_trial_id(current_branch)

parent_trial_id = Trial(trial_id).parent_id

if parent_trial_id and branch_head_trial_id != parent_trial_id:
new_branch = self.create_branch(branch_head_commit_id)
self.switch_branch(new_branch)
else:
self._set_head(current_branch_ref)

if branch_head_commit_id is not None:
commit.parents = [branch_head_commit_id]

commit.tree = tree
author = (self._commit_name + " <" + self._commit_email + ">").encode()
Expand All @@ -102,12 +118,23 @@ def create_commit_object(self, message, tree):
commit.message = message.encode()

self.repo.object_store.add_object(commit)

self.repo.refs[
self._commit_ref.encode("utf-8")
] = commit.id

self.repo.refs[
self.trial_ref(trial_id).encode("utf-8")
] = commit.id

return commit.id

def commit_content(self, message, trial_id=None):
"""Commit the current files and update branch/trial refs"""
empty_tree = Tree()
commit_id = self.create_commit_object(message, empty_tree.id, trial_id)
return commit_id

def new_tree(self, parent):
"""Create new git tree"""
return Tree()
Expand All @@ -126,6 +153,140 @@ def write_tree(self, tree):
self.repo.object_store.add_object(tree)
return tree.id

#####################################################################
## Branch Implementation
#####################################################################

def get_commit_id_by_trial_id(self, trial_id):
"""Return commit id for a trial ref"""
return self.repo.get_refs().get(self.trial_ref(trial_id).encode("utf-8"))

def get_trial_id_by_commit_id(self, commit_id):
"""Find trial id by commit id"""
if not commit_id:
return None
wanted = self._to_hex(commit_id)

prefix = self._trial_ref_prefix

with self.use_safe_open():
for ref, value in self.repo.get_refs().items():
ref = self._to_text(ref)
if ref.startswith(prefix) and value == commit_id:
return ref[len(prefix):]
return None

def get_branch_head_trial_id(self, name=None):
"""Return trial_id at the named branch head"""
"""If no name is given, return trial_id current branch head"""
return self.get_trial_id_by_commit_id(self.get_branch_head_commit_id(name))

def get_branch_head_commit_id(self, name=None):
"""Return commit_id at the named branch head"""
"""If no name is given, return commit_id current branch head"""
with self.use_safe_open():
if name != None:
branch_ref = self.branch_ref(name)
else:
branch_name, branch_ref = self.current_branch()

commit_id = self.repo.get_refs().get(branch_ref.encode("utf-8"))
return commit_id

def branches(self):
"""Return branch names in content.git"""
result = []
for ref in self.repo.get_refs():
ref = self._to_text(ref)
if ref.startswith("refs/heads/"):
result.append(ref.rsplit("/", 1)[-1])
return sorted(result)

def branch_ref(self, name):
"""Return a full Git branch ref"""
return "refs/heads/{}".format(name)

def trial_ref(self, trial_id):
"""Return a full noWorkflow trial ref"""
return self._trial_ref_prefix + str(trial_id)

def _set_head(self, branch_ref):
"""Point HEAD at a branch ref"""
ref = branch_ref.encode("utf-8")
try:
self.repo.refs.set_symbolic_ref(b"HEAD", ref)
except AttributeError:
with open(os.path.join(self.content_path, "HEAD"), "wb") as fil:
fil.write(b"ref: " + ref + b"\n")
self._commit_ref = branch_ref

def current_branch(self):
"""Return the current branch name and ref"""
with self.use_safe_open():
ref_chain, commit_sha = self.repo.refs.follow(b'HEAD')
full_name = ref_chain[1].decode('utf-8')
return full_name.rsplit("/")[-1], full_name

def create_branch(self, commit_id):
"""Create branch pointing to commit id"""
if not commit_id:
raise RuntimeError("cannot create branch without a commit")

trial_id = self.get_trial_id_by_commit_id(commit_id)

name = "now-diverge-{}".format(str(trial_id)[:8])
index = 1
existing = set(self.branches())

while name in existing:
index += 1
name = "{}-{}".format(name, index)

encoded_ref = (self.branch_ref(name)).encode("utf-8")
self.repo.refs[encoded_ref] = commit_id
return name

def switch_branch(self, name):
"""Switch HEAD to branch"""
branch_ref = self.branch_ref(name)
encoded_ref = (branch_ref).encode("utf-8")
if encoded_ref not in self.repo.get_refs():
raise RuntimeError("branch not found: {}".format(name))
self._set_head(branch_ref)

def rename_branch(self, old, new):
"""Rename a branch ref"""
old_ref = self.branch_ref(old)
new_ref = self.branch_ref(new)
refs = self.repo.get_refs()
old_key = old_ref.encode("utf-8")
new_key = new_ref.encode("utf-8")
if old_key not in refs:
raise RuntimeError("branch not found: {}".format(old))
if new_key in refs:
raise RuntimeError("branch already exists: {}".format(new))
self.repo.refs[new_key] = refs[old_key]
del self.repo.refs[old_key]

_, current_name_ref = self.current_branch()
if current_name_ref == old_ref:
self._set_head(new_ref)

@staticmethod
def _to_text(value):
if value is None:
return None
if isinstance(value, bytes):
return value.decode("utf-8")
return value

@staticmethod
def _to_hex(value):
if value is None:
return None
if isinstance(value, bytes):
return value.decode("ascii")
return str(value)

DistributedDulwichEngine = create_distributed(DulwichEngine)
PoolDulwichEngine = create_pool(DulwichEngine)
Expand Down
4 changes: 3 additions & 1 deletion src/noworkflow/now/persistence/content/gitbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ def __init__(self, config):
self._commit_name = 'Noworkflow'
self._commit_email = 'noworkflow@noworkflow.com'
self._commit_ref = 'refs/heads/master'
self._default_branch = 'master'
self._trial_ref_prefix = 'refs/noworkflow/trials/'
self._initial_message = "Initial Commit"
self.name_counter = Counter()
self.base_path = None
Expand All @@ -32,7 +34,7 @@ def set_path(self, config):
def gc(self, aggressive=False):
git_system.garbage_collection(self.content_path, aggressive)

def commit_content(self, message):
def commit_content(self, message, trial_id=None):
"""Commit the current files of content database"""
self.close()
trees = {'': self.new_tree('')}
Expand Down
2 changes: 1 addition & 1 deletion src/noworkflow/now/persistence/content/plain_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def gc(self, content_hash):
"""Do nothing for plain storage"""
pass

def commit_content(self, message):
def commit_content(self, message, trial_id=None):
"""Do nothing for plain storage"""
pass

Expand Down