Skip to content
Merged
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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,14 @@ Ephemeral ──promote──▶ Stable ──gc──▶ Deprecated
| `memora status` | Show what has changed since HEAD. |
| `memora log [--oneline] [-n N]` | Print commit history. |
| `memora branch [NAME]` | List or create branches. |
| `memora switch NAME` | Move HEAD to an existing branch. |
| `memora switch NAME` | Move HEAD to an existing branch (working set follows). |
| `memora rollback --to <commit>` | Reset HEAD to a previous commit (auto-checkpoint first). |
| `memora promote --id <NODE> \| --type <KIND> \| --all-confirmed [T]` | Promote ephemeral nodes to stable. |
| `memora diff [FROM] [TO] [--working] [--semantic]` | Show belief changes between two revisions. |
| `memora merge BRANCH [--strategy auto\|ours\|theirs] [--no-ff] [--no-commit] [--dry-run]` | Three-way merge another branch into HEAD. |

Future phases add `merge`, `replay`, `export`, `import`, `gc`, `push`,
`pull`. See `SPEC.md` for the full roadmap.
Future phases add `replay`, `export`, `import`, `gc`, `push`, `pull`. See
`SPEC.md` for the full roadmap.

---

Expand Down
32 changes: 30 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ CREATE TABLE node_versions (
PRIMARY KEY (commit_id, node_id)
);

CREATE TABLE merge_parents (
commit_id TEXT NOT NULL REFERENCES commits(id) ON DELETE CASCADE,
parent_id TEXT NOT NULL,
sequence INTEGER NOT NULL,
PRIMARY KEY (commit_id, sequence)
);

CREATE TABLE sessions (
id TEXT PRIMARY KEY,
started_at INTEGER NOT NULL,
Expand All @@ -135,8 +142,14 @@ CREATE TABLE session_events (

Indexes are `idx_nodes_kind`, `idx_nodes_status`, `idx_nodes_updated`,
`idx_commits_parent`, `idx_commits_ts`, `idx_commit_nodes_node`,
`idx_node_versions_node`, `idx_session_events_session`. They are advisory:
any tool may rebuild them.
`idx_node_versions_node`, `idx_merge_parents_commit`,
`idx_session_events_session`. They are advisory: any tool may rebuild them.

A commit with two or more parents is a **merge commit**. The *first*
parent stays in `commits.parent_id` so the canonical first-parent log walk
keeps working. Any additional parents live in `merge_parents`, ordered by
`sequence`. The full parent set of a commit is therefore
`{commits.parent_id} ∪ {merge_parents.parent_id WHERE commit_id = …}`.

`PRAGMA foreign_keys = ON` and `PRAGMA journal_mode = WAL` are required for
correctness and concurrency.
Expand All @@ -152,6 +165,21 @@ correctness and concurrency.
- **Commit id**: lowercase hex SHA-256 of
`"v1\nparent:<parent-or-empty>\ntree:<tree>\nauthor:<author>\nts:<ts>\nmsg:<msg>"`.

For a merge commit the `parent` line above contains the *first* parent
only. Every additional parent is appended on its own `parentN:<id>` line
in sequence order, e.g.:

```
v1
parent:<first>
parent2:<second>
parent3:<third>
tree:<tree>
author:<author>
ts:<ts>
msg:<msg>
```

These are the canonical formulas. Implementations MUST produce identical ids
for identical inputs.

Expand Down
56 changes: 56 additions & 0 deletions crates/memora-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ pub enum Command {

/// Show what changed between two commits (or commit vs working set).
Diff(DiffArgs),

/// Merge another branch (or commit) into HEAD.
Merge(MergeArgs),
}

/// Arguments for `memora init`.
Expand Down Expand Up @@ -198,3 +201,56 @@ pub struct DiffArgs {
#[arg(long)]
pub semantic: bool,
}

/// Arguments for `memora merge`.
#[derive(Debug, clap::Args)]
pub struct MergeArgs {
/// Branch (or commit) whose memory to merge into HEAD.
#[arg(value_name = "BRANCH")]
pub branch: String,

/// Strategy for resolving same-id divergences.
#[arg(long, value_enum, default_value_t = MergeStrategyArg::Auto)]
pub strategy: MergeStrategyArg,

/// Disable fast-forward; always create a merge commit.
#[arg(long = "no-ff")]
pub no_ff: bool,

/// Apply the merge to the working set without committing.
#[arg(long = "no-commit")]
pub no_commit: bool,

/// Override the auto-generated merge commit message.
#[arg(short = 'm', long, value_name = "MESSAGE")]
pub message: Option<String>,

/// Plan only — print what would happen and exit without changing anything.
#[arg(long = "dry-run")]
pub dry_run: bool,

/// Author for the merge commit.
#[arg(long, default_value = "human")]
pub author: String,
}

/// Convenience clap enum mirroring [`memora_core::MergeStrategy`].
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum MergeStrategyArg {
/// Score the two sides; mark genuine ties as conflicts.
Auto,
/// On any divergence, keep `ours`.
Ours,
/// On any divergence, keep `theirs`.
Theirs,
}

impl From<MergeStrategyArg> for memora_core::MergeStrategy {
fn from(v: MergeStrategyArg) -> Self {
match v {
MergeStrategyArg::Auto => memora_core::MergeStrategy::Auto,
MergeStrategyArg::Ours => memora_core::MergeStrategy::Ours,
MergeStrategyArg::Theirs => memora_core::MergeStrategy::Theirs,
}
}
}
161 changes: 161 additions & 0 deletions crates/memora-cli/src/commands/merge.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
//! `memora merge` — three-way merge another branch into HEAD.

use std::env;

use anyhow::Result;

use memora_core::{MergeKind, MergeOptions, MergeStrategy, NodeDecision, Repository};

use crate::cli::MergeArgs;
use crate::ui::{bold, cyan, dim, green, red, short_id, yellow};

/// Entry point for the `merge` subcommand.
pub fn run(args: MergeArgs) -> Result<()> {
let cwd = env::current_dir()?;
let repo = Repository::open_from(&cwd)?;
let strategy: MergeStrategy = args.strategy.into();

if args.dry_run {
let plan = repo.plan_merge(&args.branch, strategy)?;
print_plan_header(&plan, args.no_ff);
print_plan_body(&plan);
return Ok(());
}

let opts = MergeOptions {
strategy,
allow_fast_forward: !args.no_ff,
commit: !args.no_commit,
message: args.message,
author: args.author,
};
let outcome = repo.merge(&args.branch, opts)?;

match outcome.kind {
MergeKind::AlreadyUpToDate => {
println!("{}", dim("Already up to date."));
}
MergeKind::FastForward => {
let target = outcome
.commit
.as_ref()
.map(|c| short_id(&c.id).to_string())
.unwrap_or_else(|| "(unknown)".to_string());
println!(
"{} {} → {}",
bold(green("Fast-forwarded")),
short_id(&outcome.plan.ours),
yellow(target),
);
}
MergeKind::Merged => {
println!(
"{} {} into {}",
bold(green("Merged")),
short_id(&outcome.plan.theirs),
short_id(&outcome.plan.ours),
);
if let Some(c) = &outcome.commit {
println!(" merge commit: {}", yellow(short_id(&c.id)));
}
print_plan_body(&outcome.plan);
}
MergeKind::Conflicts => {
println!(
"{} merge completed with conflicts",
bold(red("Conflicts:"))
);
if let Some(c) = &outcome.commit {
println!(" merge commit: {}", yellow(short_id(&c.id)));
}
print_plan_body(&outcome.plan);
println!(
"{}",
dim("conflicted nodes were marked Conflicted in the working set; resolve manually then commit.")
);
}
MergeKind::NoCommit => {
println!("{}", bold(yellow("Plan applied to working set, no commit created.")));
print_plan_body(&outcome.plan);
}
}

Ok(())
}

fn print_plan_header(plan: &memora_core::MergePlan, no_ff: bool) {
println!(
"{} {} ← {}",
bold("merge plan"),
short_id(&plan.ours),
short_id(&plan.theirs),
);
if let Some(base) = &plan.base {
println!(" base: {}", short_id(base));
} else {
println!(" base: {}", dim("(unrelated histories)"));
}
if plan.already_up_to_date {
println!("{}", dim(" → already up to date"));
} else if plan.can_fast_forward && !no_ff {
println!("{}", dim(" → fast-forward possible"));
}
}

fn print_plan_body(plan: &memora_core::MergePlan) {
let mut updates = 0;
let mut removes = 0;
let mut conflicts = 0;
let mut auto_picks = Vec::new();
let mut conflict_lines = Vec::new();
for entry in &plan.entries {
match &entry.decision {
NodeDecision::Unchanged => {}
NodeDecision::TakeOurs(_) | NodeDecision::TakeTheirs(_) => updates += 1,
NodeDecision::Auto { ours_won, reason } => {
updates += 1;
auto_picks.push(format!(
" {} {} {} ({})",
if *ours_won {
bold(green("ours"))
} else {
bold(cyan("theirs"))
},
short_id(&entry.id),
entry
.resolved
.as_ref()
.map(|n| n.kind.to_string())
.unwrap_or_default(),
dim(reason),
));
}
NodeDecision::Conflicted { reason } => {
conflicts += 1;
conflict_lines.push(format!(
" {} {} ({})",
bold(red("conflict")),
short_id(&entry.id),
dim(reason),
));
}
NodeDecision::Removed => removes += 1,
}
}

let summary = format!("{updates} updates · {removes} removed · {conflicts} conflicted");
println!(" {summary}");

if !auto_picks.is_empty() {
println!("\n{}", bold("auto-resolved:"));
for line in auto_picks {
println!("{line}");
}
}
if !conflict_lines.is_empty() {
println!("\n{}", bold("conflicts:"));
for line in conflict_lines {
println!("{line}");
}
}
}
2 changes: 2 additions & 0 deletions crates/memora-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mod commit;
mod diff;
mod init;
mod log;
mod merge;
mod promote;
mod rollback;
mod status;
Expand All @@ -32,5 +33,6 @@ pub fn dispatch(cli: Cli) -> Result<()> {
Command::Rollback(args) => rollback::run(args),
Command::Promote(args) => promote::run(args),
Command::Diff(args) => diff::run(args),
Command::Merge(args) => merge::run(args),
}
}
Loading
Loading