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
55 changes: 55 additions & 0 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,61 @@ jrnl --config-override display_format fancy --config-override linewrap 20 \
--config-override colors.title green
```

## Git Integration

`jrnl` can automatically version your journal with git. When enabled,
every write to a journal creates a git commit, and you can optionally
sync with a remote repository.

### Enabling git for all journals

Set `backup_all_jrnls_with_git` in your config file:

```yaml
backup_all_jrnls_with_git: true
```

### Enabling git per-journal

Use the `git` key on an individual journal:

```yaml
journals:
default:
journal: ~/journal.txt
git: true
work:
journal: ~/work.txt
git: false
```

Per-journal `git` overrides the global `backup_all_jrnls_with_git`.

### Syncing with a remote

To automatically pull before reading and push after writing:

```yaml
auto_pull_from_git_remote_before_edit: true
auto_push_to_git_remote_after_edit: true
```

If the local and remote histories have diverged, `jrnl` will exit with
an error and ask you to resolve manually.

### Overriding git settings from the command line

Use `--git` to enable all git operations (commit, pull, push) for a
single invocation, or `--no-git` to disable them:

```sh
# Write an entry with git sync even if config has it disabled
jrnl --git "An important entry"

# Write an entry without any git operations
jrnl --no-git "A quick draft"
```

### Using an alternate config

You can specify an alternate configuration file for the current instance of `jrnl` using `--config-file CONFIG_FILE_PATH` where
Expand Down
11 changes: 11 additions & 0 deletions docs/reference-command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ usage: jrnl [--debug] [--help] [--version] [--list] [--encrypt] [--decrypt]
[--format TYPE] [--tags] [--short]
[--config-override CONFIG_KEY CONFIG_VALUE]
[--config-file CONFIG_FILE_PATH]
[--git | --no-git]
[[...]]
```

Expand Down Expand Up @@ -132,6 +133,16 @@ Override configured key-value pair with CONFIG_KV_PAIR for this command invocati
Use the config file at CONFIG_FILE_PATH for this command invocation only.
Read [advanced usage](./advanced.md) for examples.

## Git Integration

### --git
Enable all git operations (commit, pull, push) for this invocation,
regardless of what the config file says.

### --no-git
Disable all git operations for this invocation, regardless of what the
config file says.

## Other Arguments

### --debug
Expand Down
28 changes: 28 additions & 0 deletions docs/reference-config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,34 @@ To disable colored output, set the value to `NONE`.
### display_format
Specifies formatter to use by default. See [formats](formats.md).

### backup_all_jrnls_with_git
If `true`, automatically initializes a git repository for every journal
and commits after each write. Can be overridden per-journal with the
`git` key. See [Git Integration](advanced.md#git-integration) for details.

### git (per-journal)
Set on an individual journal to enable or disable git auto-commit for
that journal. Overrides the global `backup_all_jrnls_with_git` setting.

Example:
```yaml
journals:
default:
journal: ~/journal.txt
git: true
```

### auto_pull_from_git_remote_before_edit
If `true`, fetches from the git remote and fast-forwards the local
branch before reading journal entries. Requires a remote to be
configured in the journal's git repository. If the local and remote
histories have diverged, `jrnl` will exit with an error asking you to
resolve the conflict manually.

### auto_push_to_git_remote_after_edit
If `true`, pushes to the git remote after committing a journal write.
Requires a remote to be configured in the journal's git repository.

### version
`jrnl` automatically updates this field to the version that it is running.
There is no need to change this field manually.
Expand Down
17 changes: 17 additions & 0 deletions jrnl/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,23 @@ def parse_args(args: list[str] = []) -> argparse.Namespace:
"--cf", dest="config_file_path", type=str, default="", help=argparse.SUPPRESS
)

git_group = parser.add_argument_group("Git Integration")
git_mutex = git_group.add_mutually_exclusive_group()
git_mutex.add_argument(
"--git",
dest="git_enabled",
action="store_true",
default=None,
help="Enable all git operations (commit, pull, push)",
)
git_mutex.add_argument(
"--no-git",
dest="git_enabled",
action="store_false",
default=None,
help="Disable all git operations for this invocation",
)

# Handle '-123' as a shortcut for '-n 123'
num = re.compile(r"^-(\d+)$")
args = [num.sub(r"-n \1", arg) for arg in args]
Expand Down
43 changes: 39 additions & 4 deletions jrnl/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,36 @@ def save_config(config: dict, alt_config_path: str | None = None) -> None:
"""Supply alt_config_path if using an alternate config through --config-file."""
config["version"] = __version__

yaml = YAML(typ="safe")
yaml = YAML(typ="rt")
yaml.default_flow_style = False # prevents collapsing of tree structure

# Sort top-level keys alphabetically so the config file stays tidy
# regardless of the order keys were added over time.
from ruamel.yaml.comments import CommentedMap
from ruamel.yaml.comments import CommentedSeq

if not isinstance(config, CommentedMap):
config = CommentedMap(config)

# When a config was originally loaded from JSON, ruamel's round-trip
# loader marks every mapping/sequence with flow style. We must clear
# that flag recursively, otherwise the saved file starts with '{' and
# is_config_json() will mistake it for a legacy v1.x config.
def _force_block_style(node):
if isinstance(node, CommentedMap):
node.fa.set_block_style()
for v in node.values():
_force_block_style(v)
elif isinstance(node, CommentedSeq):
node.fa.set_block_style()
for item in node:
_force_block_style(item)

_force_block_style(config)

for key in sorted(config.keys()):
config.move_to_end(key)

with open(
alt_config_path if alt_config_path else get_config_path(),
"w",
Expand All @@ -74,9 +101,14 @@ def save_config(config: dict, alt_config_path: str | None = None) -> None:


def get_default_config() -> dict[str, Any]:
# Load with a placeholder first, then assign the real path as a Python string.
# Inlining the path directly into the YAML string would break on paths that
# contain YAML-special characters (colons, backslashes, spaces).
default_journal = YAML(typ="rt").load("journal: placeholder\n# git: false\n")
default_journal["journal"] = get_default_journal_path()
return {
"version": __version__,
"journals": {"default": {"journal": get_default_journal_path()}},
"journals": {"default": default_journal},
"editor": os.getenv("VISUAL") or os.getenv("EDITOR") or "",
"encrypt": False,
"template": False,
Expand All @@ -87,6 +119,9 @@ def get_default_config() -> dict[str, Any]:
"highlight": True,
"linewrap": 79,
"indent_character": "|",
"backup_all_jrnls_with_git": False,
"auto_pull_from_git_remote_before_edit": False,
"auto_push_to_git_remote_after_edit": False,
"colors": {
"body": "none",
"date": "none",
Expand Down Expand Up @@ -153,7 +188,7 @@ def load_config(config_path: str) -> dict:
"""Tries to load a config file from YAML."""
try:
with open(config_path, encoding=YAML_FILE_ENCODING) as f:
yaml = YAML(typ="safe")
yaml = YAML(typ="rt")
yaml.allow_duplicate_keys = False
return yaml.load(f)
except constructor.DuplicateKeyError as e:
Expand All @@ -167,7 +202,7 @@ def load_config(config_path: str) -> dict:
)
)
with open(config_path, encoding=YAML_FILE_ENCODING) as f:
yaml = YAML(typ="safe")
yaml = YAML(typ="rt")
yaml.allow_duplicate_keys = True
return yaml.load(f)

Expand Down
7 changes: 7 additions & 0 deletions jrnl/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ def run(args: "Namespace"):

# --- All the standalone commands are now done --- #

# CLI flags override config for git operations
if args.git_enabled is not None:
config["git"] = args.git_enabled
config["backup_all_jrnls_with_git"] = args.git_enabled
config["auto_pull_from_git_remote_before_edit"] = args.git_enabled
config["auto_push_to_git_remote_after_edit"] = args.git_enabled

# Get the journal we're going to be working with
journal = open_journal(args.journal_name, config)

Expand Down
147 changes: 147 additions & 0 deletions jrnl/git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Copyright © 2012-2023 jrnl contributors
# License: https://www.gnu.org/licenses/gpl-3.0.html

import logging
from pathlib import Path

import git

from jrnl.exception import JrnlException
from jrnl.messages import Message
from jrnl.messages import MsgStyle
from jrnl.messages import MsgText
from jrnl.output import print_msg


def git_pull(path: Path) -> None:
"""Fetch from the remote and fast-forward if possible.

Uses fetch + fast-forward instead of pull for better performance:
fetch only updates remote refs without touching the working tree,
so the common "already up to date" case is very cheap. We only
touch the working tree when there are actual new commits to
fast-forward to.

Raises JrnlException if the histories have diverged (not
fast-forwardable) so the user can resolve manually.
"""
path = path.resolve()
repo_dir = path.parent if path.is_file() else path

try:
repo = git.Repo(repo_dir)
except (git.exc.InvalidGitRepositoryError, git.exc.NoSuchPathError):
return

if not repo.remotes:
logging.debug("git: no remotes configured, skipping pull")
return

if not repo.head.is_valid():
logging.debug("git: repo has no commits yet, skipping pull")
return

try:
remote = repo.remotes[0]
branch = repo.active_branch.name
remote.fetch()

remote_ref = f"{remote.name}/{branch}"
if remote_ref not in [ref.name for ref in remote.refs]:
logging.debug("git: remote branch %s not found, skipping pull", remote_ref)
return

local_commit = repo.head.commit
remote_commit = repo.commit(remote_ref)

if local_commit == remote_commit:
logging.debug("git: already up to date with %s", remote.name)
print_msg(
Message(MsgText.GitUpToDate, MsgStyle.NORMAL, {"url": remote.url})
)
return

# Check if remote is a descendant of local (i.e. fast-forwardable)
if repo.is_ancestor(local_commit, remote_commit):
repo.head.reset(remote_commit, index=True, working_tree=True)
logging.debug("git: fast-forwarded to %s", remote.name)
print_msg(Message(MsgText.GitPulled, MsgStyle.NORMAL, {"url": remote.url}))
else:
logging.warning("git: local and remote have diverged")
raise JrnlException(
Message(
MsgText.GitPullDiverged,
MsgStyle.ERROR,
{"path": repo_dir},
)
)

except git.exc.GitCommandNotFound:
logging.warning("git not found; skipping pull")
except git.exc.GitCommandError as e:
logging.warning("git fetch failed: %s", e)
raise JrnlException(
Message(
MsgText.GitPullFailed,
MsgStyle.ERROR,
{"path": repo_dir},
)
)


def git_auto_commit(
path: Path, message: str = "jrnl CLI: auto-commit", push: bool = False
) -> None:
"""Initialize a git repo if needed, then stage and commit journal changes."""
path = path.resolve()
repo_dir = path.parent if path.is_file() else path

try:
try:
repo = git.Repo(repo_dir)
except git.exc.InvalidGitRepositoryError:
repo = git.Repo.init(repo_dir)

# Always add only the journal path — never stage unrelated files that
# may exist in the same repo. -A ensures deletions are staged too;
# plain `git add <dir>` stages additions and modifications but skips
# removals (relevant for folder journals, which delete empty day files).
# For single files, -A is equivalent to plain git add.
repo.git.add("-A", str(path))

# Check for staged changes; handle new repo (no HEAD) separately
if repo.head.is_valid():
has_staged = bool(repo.index.diff("HEAD"))
else:
has_staged = bool(repo.index.entries)

if has_staged:
repo.index.commit(message)
logging.debug("git: committed changes in %s", repo_dir)
print_msg(
Message(MsgText.GitCommitted, MsgStyle.NORMAL, {"path": repo_dir})
)
if push:
_push_to_remote(repo)
else:
logging.debug("git: no changes to commit in %s", repo_dir)

except git.exc.GitCommandNotFound:
logging.warning("git not found; skipping auto-commit")
except git.exc.GitCommandError as e:
logging.warning("git auto-commit failed: %s", e)


def _push_to_remote(repo: git.Repo) -> None:
"""Push to the first configured remote. Logs a warning on failure."""
if not repo.remotes:
logging.debug("git: no remotes configured, skipping push")
return
try:
remote = repo.remotes[0]
branch = repo.active_branch.name
remote.push(refspec=f"{branch}:{branch}")
logging.debug("git: pushed to %s", remote.name)
print_msg(Message(MsgText.GitPushed, MsgStyle.NORMAL, {"url": remote.url}))
except git.exc.GitCommandError as e:
logging.warning("git push failed: %s", e)
Loading