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
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ sha2 = "0.10"
tempfile = "3"
fsc-core = { path = "crates/fsc-core" }
fsc-scope = { path = "crates/fsc-scope" }
fsc-merge = { path = "crates/fsc-merge" }

[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
16 changes: 16 additions & 0 deletions crates/fsc-merge/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[package]
name = "fsc-merge"
version.workspace = true
edition.workspace = true
license.workspace = true

[dependencies]
fsc-core.workspace = true
serde.workspace = true
serde_json.workspace = true

[dev-dependencies]
tempfile.workspace = true

[lints]
workspace = true
106 changes: 106 additions & 0 deletions crates/fsc-merge/src/conflict.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! Git conflict-marker parsing. Behaviour matches `utils/conflictParser.ts` line for line,
//! including that the separator must be exactly `=======` (a CRLF file leaves a trailing `\r`,
//! which the TS version also fails to recognise).

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictChunk {
/// Line offset of the `<<<<<<<` line (0-based).
pub start_line: usize,
/// Content from HEAD (the canonical / target side).
pub ours: String,
/// Content from the incoming (agent) side.
pub theirs: String,
}

fn is_ours(line: &str) -> bool {
marker(line, '<')
}

fn is_theirs(line: &str) -> bool {
marker(line, '>')
}

fn is_separator(line: &str) -> bool {
line == "======="
}

/// Seven `c` followed by a whitespace character.
fn marker(line: &str, c: char) -> bool {
let mut it = line.chars();
for _ in 0..7 {
if it.next() != Some(c) {
return false;
}
}
it.next().is_some_and(char::is_whitespace)
}

/// Parse conflict chunks. Empty when the content has no markers.
pub fn parse_conflict_markers(content: &str) -> Vec<ConflictChunk> {
let lines: Vec<&str> = content.split('\n').collect();
let mut chunks = vec![];
let mut i = 0;
while i < lines.len() {
if !is_ours(lines[i]) {
i += 1;
continue;
}
let start_line = i;
let (mut ours, mut theirs) = (vec![], vec![]);
let mut in_ours = true;
i += 1;
while i < lines.len() {
if is_separator(lines[i]) {
in_ours = false;
i += 1;
continue;
}
if is_theirs(lines[i]) {
break;
}
if in_ours {
ours.push(lines[i]);
} else {
theirs.push(lines[i]);
}
i += 1;
}
chunks.push(ConflictChunk {
start_line,
ours: ours.join("\n"),
theirs: theirs.join("\n"),
});
i += 1;
}
chunks
}

/// True when the HEAD side has non-whitespace content, i.e. keep-incoming would discard real work.
pub fn has_contentful_canonical(chunk: &ConflictChunk) -> bool {
!chunk.ours.trim().is_empty()
}

/// Resolve every conflict by keeping the incoming side.
pub fn resolve_keep_incoming(content: &str) -> String {
let lines: Vec<&str> = content.split('\n').collect();
let mut out: Vec<&str> = vec![];
let mut i = 0;
while i < lines.len() {
if !is_ours(lines[i]) {
out.push(lines[i]);
i += 1;
continue;
}
i += 1;
while i < lines.len() && !is_separator(lines[i]) {
i += 1;
}
i += 1;
while i < lines.len() && !is_theirs(lines[i]) {
out.push(lines[i]);
i += 1;
}
i += 1;
}
out.join("\n")
}
39 changes: 39 additions & 0 deletions crates/fsc-merge/src/git.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use std::path::Path;
use std::process::{Command, Stdio};

#[derive(Debug, Clone)]
pub struct GitOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}

impl GitOutput {
pub fn ok(&self) -> bool {
self.exit_code == 0
}
}

/// Run `git <args>` in `cwd`. Never prompts and never opens an editor.
/// A spawn failure is reported as exit code 1 with the OS error in `stderr`.
pub fn git(args: &[&str], cwd: &Path) -> GitOutput {
let out = Command::new("git")
.args(args)
.current_dir(cwd)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_EDITOR", "true")
.stdin(Stdio::null())
.output();
match out {
Ok(o) => GitOutput {
stdout: String::from_utf8_lossy(&o.stdout).into_owned(),
stderr: String::from_utf8_lossy(&o.stderr).into_owned(),
exit_code: o.status.code().unwrap_or(1),
},
Err(e) => GitOutput {
stdout: String::new(),
stderr: e.to_string(),
exit_code: 1,
},
}
}
13 changes: 13 additions & 0 deletions crates/fsc-merge/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! Serialized commit merging with tiered conflict resolution
//! (port of `packages/core/src/execution/sequencer.ts` and `utils/conflictParser.ts`).

pub mod conflict;
pub mod git;
pub mod sequencer;

pub use conflict::{
has_contentful_canonical, parse_conflict_markers, resolve_keep_incoming, ConflictChunk,
};
pub use sequencer::{
CommitSequencer, MergeHistory, MergeHistoryEntry, MergeInput, SequencerResult,
};
Loading
Loading