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
123 changes: 88 additions & 35 deletions core/continuum-hands/src/code/git_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,33 +44,33 @@ pub async fn git_status(workspace_root: &Path) -> GitStatusInfo {
let mut untracked = Vec::new();

for line in status_output.lines() {
if line.len() < 3 {
// Porcelain v1: the index status, the work-tree status, a space, then the path.
let mut chars = line.chars();
let (Some(index), Some(tree), Some(' ')) = (chars.next(), chars.next(), chars.next()) else {
continue;
}

let status_code = &line[..2];
let file_path = line[3..].trim().to_string();

// Parse porcelain v1 status codes
// First char = index status, second char = work tree status
match status_code {
"??" => untracked.push(file_path),
s if s.starts_with('A') || s.ends_with('A') => added.push(file_path),
s if s.starts_with('D') || s.ends_with('D') => deleted.push(file_path),
s if s.starts_with('M')
|| s.ends_with('M')
|| s.starts_with('R')
|| s.ends_with('R')
|| s.starts_with('C')
|| s.ends_with('C') =>
{
modified.push(file_path)
}
_ => {
// Catch-all: treat as modified
if !file_path.is_empty() {
modified.push(file_path);
}
};
let file_path = chars.as_str().trim().to_string();
let either = |code: char| index == code || tree == code;

match (index, tree) {
('?', '?') => untracked.push(file_path),
_ if either('A') => added.push(file_path),
_ if either('D') => deleted.push(file_path),
// M modified, R renamed, C copied, T type changed, U unmerged: the path's content
// differs from HEAD. `!` is an ignored path (not reported). Porcelain v1 has no
// other codes, so the match names every one.
_ if either('M') || either('R') || either('C') || either('T') || either('U') => modified.push(file_path),
('!', '!') => {}
(index, tree) => {
return GitStatusInfo {
success: false,
branch,
modified: Vec::new(),
added: Vec::new(),
deleted: Vec::new(),
untracked: Vec::new(),
error: Some(format!("git status reported an unknown porcelain status {index:?}{tree:?} for {file_path}")),
};
}
}
}
Expand All @@ -86,16 +86,69 @@ pub async fn git_status(workspace_root: &Path) -> GitStatusInfo {
}
}

/// Get git diff output for the workspace.
///
/// If `staged` is true, shows staged changes (--cached).
/// Otherwise shows unstaged working tree changes.
pub async fn git_diff(workspace_root: &Path, staged: bool) -> Result<String, String> {
let mut args = vec!["diff"];
if staged {
args.push("--cached");
// A resident's edits are checkpointed into wip(...) commits every act, so a working-tree-only
// diff is always empty and read as "my edits were phantom" (Aiko re-read the same files for 80
// minutes, 2026-11-02). These doc lines are the schema text every mind reads: keep them short.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema, ts_rs::TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../../protocol/typescript/code/DiffScope.ts")]
pub enum DiffScope {
// All her work since her branch started, checkpoints included.
#[default]
YourWork,
Unstaged,
Staged,
}

/// A diff and what it was taken against, so a reader knows what an empty one means.
pub struct Diff {
pub text: String,
pub against: String,
}

/// Git diff output for the workspace, in `scope`. `YourWork` needs the checkout's recorded work
/// base and refuses without one; it never falls back to a comparison that would read empty.
pub async fn git_diff(workspace_root: &Path, scope: DiffScope) -> Result<Diff, String> {
match scope {
DiffScope::Unstaged => Ok(Diff { text: run_git(workspace_root, &["diff"]).await?, against: "the index".into() }),
DiffScope::Staged => Ok(Diff { text: run_git(workspace_root, &["diff", "--cached"]).await?, against: "HEAD (staged)".into() }),
DiffScope::YourWork => {
let base = work_base(workspace_root).await?;
Ok(Diff {
text: run_git(workspace_root, &["diff", &base.sha]).await?,
against: format!("{} (where this branch left {})", base.short, base.from),
})
}
}
run_git(workspace_root, &args).await
}

/// Where this checkout's work starts: the merge-base of HEAD with the integration ref the
/// checkout's owner recorded at allocation (airc's `AllocationEvidence`), so a branch rebased
/// onto a newer integration tip still diffs from where it actually left it.
pub struct WorkBase {
pub sha: String,
/// Git's own unambiguous short form of `sha`.
pub short: String,
/// The recorded integration ref the base was taken on.
pub from: String,
}

/// The recorded work base of the checkout at `workspace_root`. A checkout with no allocation
/// record (one airc did not allocate) has no known base, and says so; git failures propagate.
pub async fn work_base(workspace_root: &Path) -> Result<WorkBase, String> {
let root = workspace_root.to_path_buf();
let evidence = tokio::task::spawn_blocking(move || airc_lib::work_worktree::allocation_evidence(&root))
.await
.map_err(|e| format!("reading the checkout's allocation record: {e}"))??
.ok_or_else(|| {
"this checkout has no allocation record, so where its work starts is unknown; \
ask for scope `unstaged` or `staged`"
.to_owned()
})?;
let from = evidence.start_ref;
let sha = run_git(workspace_root, &["merge-base", "HEAD", &from]).await?.trim().to_owned();
let short = run_git(workspace_root, &["rev-parse", "--short", &sha]).await?.trim().to_owned();
Ok(WorkBase { sha, short, from })
}

/// The `owner/name` board key this checkout belongs to, read from `origin`.
Expand Down
48 changes: 47 additions & 1 deletion core/continuum-hands/src/code/git_bridge/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,11 +374,57 @@
let dir = setup_git_repo().await;
fs::write(dir.path().join("initial.txt"), "modified\n").unwrap();

let diff = git_diff(dir.path(), false).await.unwrap();
let diff = git_diff(dir.path(), DiffScope::Unstaged).await.unwrap().text;
assert!(diff.contains("-hello"));
assert!(diff.contains("+modified"));
}

/// Record allocation evidence the way airc's checkout owner does: the JSON blob and its
/// creation anchor as worktree-private refs.
async fn record_allocation(root: &std::path::Path, start_ref: &str) {
let creation = run_git(root, &["rev-parse", "HEAD"]).await.unwrap().trim().to_owned();
let evidence = format!(
r#"{{"repo":"o/r","requested_branch":"b","integration_base":"canary","start_ref":"{start_ref}","creation_sha":"{creation}"}}"#
);
let blob = run_git_input(root, &["hash-object", "-w", "--stdin"], &[], Some(evidence.into_bytes())).await.unwrap();
run_git(root, &["update-ref", airc_lib::work_worktree::ALLOCATION_REF, blob.trim()]).await.unwrap();
run_git(root, &["update-ref", airc_lib::work_worktree::CREATION_BASE_REF, &creation]).await.unwrap();
}

// what this catches: a resident's work checkpointed into commits every act reading as
// nothing done. The working-tree diff is empty after a checkpoint; her work is not
// (Aiko re-read the same files for 80 minutes on 2026-11-02 believing her edits phantom).
// A newer ref pointing AT her checkpoint must not become the base (a date-ranked guess
// picked it and read empty again; Codex's review of cd36f0c8).
#[tokio::test]
async fn your_work_is_taken_from_the_recorded_base_checkpoints_included() {
let dir = setup_git_repo().await;
let root = dir.path();
run_git(root, &["update-ref", "refs/remotes/origin/canary", "HEAD"]).await.unwrap();
record_allocation(root, "refs/remotes/origin/canary").await;
fs::write(root.join("initial.txt"), "her edit\n").unwrap();
run_git(root, &["commit", "-qam", "wip(card): continuum act on node n"]).await.unwrap();
run_git(root, &["update-ref", "refs/remotes/origin/main", "HEAD"]).await.unwrap();

assert!(git_diff(root, DiffScope::Unstaged).await.unwrap().text.is_empty(), "the checkpoint left the tree clean");
let work = git_diff(root, DiffScope::YourWork).await.unwrap();
assert!(work.text.contains("+her edit"), "her committed work is her work: {}", work.text);
assert!(work.against.contains("refs/remotes/origin/canary"), "the recorded ref, not the newer one: {}", work.against);
}

// what this catches: a checkout with committed work but no allocation record reading as an
// empty diff (the old fallback to `git diff HEAD`). Without a recorded base, YourWork refuses.
#[tokio::test]
async fn your_work_without_a_recorded_base_refuses_instead_of_reading_empty() {
let dir = setup_git_repo().await;
let root = dir.path();
fs::write(root.join("initial.txt"), "committed\n").unwrap();
run_git(root, &["commit", "-qam", "work"]).await.unwrap();
let refused = git_diff(root, DiffScope::YourWork).await.err().expect("no recorded base refuses");
assert!(refused.contains("allocation record"), "{refused}");
assert!(git_diff(root, DiffScope::Unstaged).await.is_ok(), "explicit scopes stay usable");
}

#[tokio::test]
async fn test_git_log() {
let dir = setup_git_repo().await;
Expand Down
2 changes: 1 addition & 1 deletion core/continuum-hands/src/commands/code/git/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ mod tests {
}
// Author edits + stages; diff of the staged change is the shared patch.
std::fs::write(a.join("life.rs"), "fn main() { println!(\"glider\"); }\n").unwrap();
let patch = git_bridge::git_diff(&a, false).await.expect("diff");
let patch = git_bridge::git_diff(&a, git_bridge::DiffScope::Unstaged).await.expect("diff").text;
assert!(
patch.contains("glider"),
"patch carries the change: {patch}"
Expand Down
16 changes: 9 additions & 7 deletions core/continuum-hands/src/commands/code/git/diff.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! `code/git/diff` — unified diff of the caller's workspace (unstaged or staged).
//! `code/git/diff` — unified diff of the caller's workspace: by default her work since her
//! branch left its integration branch, or the unstaged or staged changes.

use std::sync::Arc;

Expand All @@ -17,9 +18,9 @@ use crate::sdk_codegen::CommandError;
export_to = "../../../protocol/typescript/code/GitDiffParams.ts"
)]
pub struct GitDiffParams {
/// Show STAGED changes (`--cached`) instead of unstaged working-tree changes.
/// Default `yourWork`: all your work since your branch started.
#[serde(default)]
pub staged: bool,
pub scope: git_bridge::DiffScope,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
Expand All @@ -30,11 +31,12 @@ pub struct GitDiffParams {
pub struct GitDiffResult {
/// The raw unified diff text.
pub diff: String,
/// What the diff compared against.
pub against: String,
}

crate::action_command! {
/// Show the git diff for your workspace. By default shows unstaged working-tree
/// changes; set `staged: true` for the staged (`--cached`) diff.
/// Show the git diff of your work (default: everything since your branch started).
pub struct CodeGitDiff { state: Arc<CodeState> }
name: "code/git/diff",
access: AiSafe,
Expand All @@ -45,7 +47,7 @@ crate::action_command! {
output: GitDiffResult,
run(this, ctx, p) => {
let root = workspace_root_for(&this.state, ctx).await?;
let diff = git_bridge::git_diff(&root, p.staged).await.map_err(CommandError::Internal)?;
Ok(GitDiffResult { diff })
let diff = git_bridge::git_diff(&root, p.scope).await.map_err(CommandError::Internal)?;
Ok(GitDiffResult { diff: diff.text, against: diff.against })
}
}
3 changes: 3 additions & 0 deletions protocol/typescript/code/DiffScope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type DiffScope = "yourWork" | "unstaged" | "staged";
5 changes: 3 additions & 2 deletions protocol/typescript/code/GitDiffParams.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DiffScope } from "./DiffScope";

export type GitDiffParams = {
/**
* Show STAGED changes (`--cached`) instead of unstaged working-tree changes.
* Default `yourWork`: all your work since your branch started.
*/
staged: boolean, };
scope: DiffScope, };
6 changes: 5 additions & 1 deletion protocol/typescript/code/GitDiffResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ export type GitDiffResult = {
/**
* The raw unified diff text.
*/
diff: string, };
diff: string,
/**
* What the diff compared against.
*/
against: string, };
1 change: 1 addition & 0 deletions protocol/typescript/code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
export type { ChangeNode } from './ChangeNode';
export type { ClassifiedLine } from './ClassifiedLine';
export type { DiffHunk } from './DiffHunk';
export type { DiffScope } from './DiffScope';
export type { DirEntry } from './DirEntry';
export type { EditMode } from './EditMode';
export type { ExistsResult } from './ExistsResult';
Expand Down
Loading